mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ADD - support home screen app folders
This commit is contained in:
@@ -2263,13 +2263,36 @@ button {
|
||||
display: flex;
|
||||
touch-action: pan-y;
|
||||
transform: translateX(calc(var(--springboard-offset) + var(--drag-offset)));
|
||||
transition: transform var(--springboard-page-duration)
|
||||
var(--springboard-page-easing);
|
||||
transition:
|
||||
transform var(--springboard-page-duration) var(--springboard-page-easing),
|
||||
filter 0.22s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
.springboard--dragging .springboard-track {
|
||||
transition: none;
|
||||
}
|
||||
.springboard--folder-open .springboard-track,
|
||||
.springboard--folder-open .app-dock,
|
||||
.springboard--folder-open .page-indicator,
|
||||
.springboard--folder-open .springboard-edit-add,
|
||||
.springboard--folder-open .springboard-edit-done {
|
||||
filter: blur(6px) brightness(0.72) saturate(0.88);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-folder-enter-active,
|
||||
.home-folder-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.24s cubic-bezier(0.22, 0.75, 0.18, 1);
|
||||
transform-origin: 50% 42%;
|
||||
}
|
||||
|
||||
.home-folder-enter-from,
|
||||
.home-folder-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.springboard-page {
|
||||
position: relative;
|
||||
flex: none;
|
||||
@@ -6558,7 +6581,7 @@ button {
|
||||
display: none;
|
||||
}
|
||||
.app-grid {
|
||||
grid-template-rows: repeat(5, var(--springboard-grid-row));
|
||||
grid-template-rows: repeat(6, var(--springboard-grid-row));
|
||||
gap: var(--springboard-grid-row-gap) var(--springboard-grid-column-gap);
|
||||
}
|
||||
.app-grid-slot {
|
||||
@@ -6600,8 +6623,36 @@ button {
|
||||
}
|
||||
.app-reflow-move {
|
||||
z-index: 1;
|
||||
transition: transform 0.58s cubic-bezier(0.2, 0.82, 0.2, 1);
|
||||
transition: transform 0.34s cubic-bezier(0.22, 0.75, 0.18, 1);
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.app-icon-item--drop-settling {
|
||||
z-index: 2;
|
||||
animation: home-app-drop-settle 0.26s cubic-bezier(0.22, 0.75, 0.18, 1);
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.home-item--folder-hover:not(.app-icon-item--dragging) {
|
||||
z-index: 3;
|
||||
animation: home-folder-hover 0.58s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes home-folder-hover {
|
||||
to {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@keyframes home-app-drop-settle {
|
||||
from {
|
||||
transform: translate3d(
|
||||
var(--home-app-drop-x, 0),
|
||||
var(--home-app-drop-y, 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.springboard-page-add {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
import type { HomeFolder } from '@/utils/homeLayout'
|
||||
import {
|
||||
reorderDirectionFromKeyboard,
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
apps: PhoneAppDefinition[]
|
||||
defaultName: string
|
||||
editMode?: boolean
|
||||
folder: HomeFolder
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
dragcancel: []
|
||||
dragend: [event: PointerEvent]
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [event: PointerEvent]
|
||||
edit: []
|
||||
open: []
|
||||
reorder: [direction: ReorderDirection]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const isDragging = ref(false)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const suppressClick = ref(false)
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let holdTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
let pointerId: number | null = null
|
||||
|
||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`,
|
||||
translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
|
||||
function clearHold(): void {
|
||||
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
|
||||
holdTimer = undefined
|
||||
}
|
||||
|
||||
function releasePointerCapture(): void {
|
||||
if (
|
||||
pointerTarget &&
|
||||
pointerId !== null &&
|
||||
pointerTarget.hasPointerCapture(pointerId)
|
||||
) {
|
||||
pointerTarget.releasePointerCapture(pointerId)
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
}
|
||||
|
||||
function beginPointerDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.target as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
isDragging.value = true
|
||||
emit('dragstart', event)
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (event.button !== 0) return
|
||||
pointerTarget = event.currentTarget as HTMLElement
|
||||
pointerId = event.pointerId
|
||||
pointerTarget.setPointerCapture(pointerId)
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
beginPointerDrag(event)
|
||||
return
|
||||
}
|
||||
holdTimer = window.setTimeout(() => {
|
||||
suppressClick.value = true
|
||||
emit('edit')
|
||||
beginPointerDrag(event)
|
||||
holdTimer = undefined
|
||||
}, 520)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (isDragging.value) {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
emit('dragmove', event)
|
||||
return
|
||||
}
|
||||
if (
|
||||
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
|
||||
8
|
||||
) {
|
||||
clearHold()
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent): void {
|
||||
clearHold()
|
||||
if (isDragging.value) {
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
}
|
||||
releasePointerCapture()
|
||||
}
|
||||
|
||||
function cancelPointerDrag(): void {
|
||||
clearHold()
|
||||
const wasDragging = isDragging.value
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
releasePointerCapture()
|
||||
if (wasDragging) emit('dragcancel')
|
||||
}
|
||||
|
||||
function openFolder(): void {
|
||||
if (props.editMode || suppressClick.value) {
|
||||
suppressClick.value = false
|
||||
return
|
||||
}
|
||||
emit('open')
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (!props.editMode) return
|
||||
const direction = reorderDirectionFromKeyboard(event)
|
||||
if (!direction) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('reorder', direction)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHold()
|
||||
releasePointerCapture()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="home-folder-item app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
'home-folder-item--dragging': isDragging,
|
||||
}"
|
||||
:style="dragStyle"
|
||||
>
|
||||
<button
|
||||
class="home-folder-button app-icon-button"
|
||||
type="button"
|
||||
:aria-label="folderName"
|
||||
:aria-keyshortcuts="
|
||||
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
|
||||
"
|
||||
@click="openFolder"
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@lostpointercapture="cancelPointerDrag"
|
||||
@pointercancel="cancelPointerDrag"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<span class="home-folder-preview" aria-hidden="true">
|
||||
<span
|
||||
v-for="app in apps.slice(0, 9)"
|
||||
:key="app.id"
|
||||
class="home-folder-preview__app"
|
||||
>
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="showLabel" class="app-icon-label">{{ folderName }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-folder-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-folder-preview {
|
||||
box-sizing: border-box;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 22%);
|
||||
border-radius: 14px;
|
||||
background: rgb(210 220 235 / 42%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 20%),
|
||||
0 2px 8px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.home-folder-preview__app {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.home-folder-preview__app img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,558 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil } from 'lucide-vue-next'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
SkyButton,
|
||||
SkyField,
|
||||
SkyLink,
|
||||
SkySheet,
|
||||
} from '@/ui'
|
||||
import {
|
||||
HOME_FOLDER_NAME_MAX_LENGTH,
|
||||
HOME_FOLDER_PAGE_SIZE,
|
||||
type HomeFolder,
|
||||
} from '@/utils/homeLayout'
|
||||
|
||||
type FolderAppEntry = {
|
||||
app: PhoneAppDefinition
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
apps: FolderAppEntry[]
|
||||
editMode: boolean
|
||||
folder: HomeFolder
|
||||
renameOnOpen: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
edit: []
|
||||
extract: [index: number, event: PointerEvent]
|
||||
move: [sourceIndex: number, targetIndex: number]
|
||||
rename: [name: string]
|
||||
'rename-opened': []
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const currentPage = ref(0)
|
||||
const pageTransitionDirection = ref<'backward' | 'forward'>('forward')
|
||||
const draggingIndex = ref<number | null>(null)
|
||||
const renameOpened = ref(false)
|
||||
const renameDraft = ref('')
|
||||
let pagePointerStart = 0
|
||||
let pagePointerId: number | null = null
|
||||
|
||||
const folderName = computed(
|
||||
() => props.folder.name || phone.t('Home.folders.defaultName'),
|
||||
)
|
||||
const pageCount = computed(() =>
|
||||
Math.max(1, Math.ceil(props.folder.apps.length / HOME_FOLDER_PAGE_SIZE)),
|
||||
)
|
||||
const pageTransitionName = computed(
|
||||
() => `home-folder-page-${pageTransitionDirection.value}`,
|
||||
)
|
||||
const visibleApps = computed(() => {
|
||||
const start = currentPage.value * HOME_FOLDER_PAGE_SIZE
|
||||
return props.apps.filter(
|
||||
(entry) =>
|
||||
entry.index >= start && entry.index < start + HOME_FOLDER_PAGE_SIZE,
|
||||
)
|
||||
})
|
||||
|
||||
watch(pageCount, (count) => {
|
||||
const nextPage = Math.min(currentPage.value, count - 1)
|
||||
if (nextPage === currentPage.value) return
|
||||
pageTransitionDirection.value = 'backward'
|
||||
currentPage.value = nextPage
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.folder.name,
|
||||
(name) => {
|
||||
if (!renameOpened.value) renameDraft.value = name || folderName.value
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.renameOnOpen,
|
||||
async (shouldOpen) => {
|
||||
if (!shouldOpen) return
|
||||
renameDraft.value = props.folder.name || folderName.value
|
||||
renameOpened.value = true
|
||||
emit('rename-opened')
|
||||
await nextTick()
|
||||
document
|
||||
.querySelector<HTMLInputElement>('.home-folder-rename .sky-field__input')
|
||||
?.select()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function openRename(): void {
|
||||
renameDraft.value = props.folder.name || folderName.value
|
||||
renameOpened.value = true
|
||||
void nextTick(() => {
|
||||
document
|
||||
.querySelector<HTMLInputElement>('.home-folder-rename .sky-field__input')
|
||||
?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function saveRename(): void {
|
||||
emit('rename', renameDraft.value.trim() || phone.t('Home.folders.defaultName'))
|
||||
renameOpened.value = false
|
||||
}
|
||||
|
||||
function startFolderAppDrag(index: number): void {
|
||||
draggingIndex.value = index
|
||||
}
|
||||
|
||||
function finishFolderAppDrag(event: PointerEvent): void {
|
||||
const sourceIndex = draggingIndex.value
|
||||
draggingIndex.value = null
|
||||
if (sourceIndex === null) return
|
||||
const panel = document.querySelector<HTMLElement>('.home-folder-panel')
|
||||
if (!panel) return
|
||||
const panelBounds = panel.getBoundingClientRect()
|
||||
const insidePanel =
|
||||
event.clientX >= panelBounds.left &&
|
||||
event.clientX <= panelBounds.right &&
|
||||
event.clientY >= panelBounds.top &&
|
||||
event.clientY <= panelBounds.bottom
|
||||
if (!insidePanel) {
|
||||
emit('extract', sourceIndex, event)
|
||||
return
|
||||
}
|
||||
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-folder-app-index]'))
|
||||
.find(
|
||||
(element) =>
|
||||
element && Number(element.dataset.folderAppIndex) !== sourceIndex,
|
||||
)
|
||||
if (!target) return
|
||||
const targetIndex = Number(target.dataset.folderAppIndex)
|
||||
if (Number.isInteger(targetIndex)) emit('move', sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function stopFolderAppDrag(): void {
|
||||
draggingIndex.value = null
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
const nextPage = Math.max(0, Math.min(pageCount.value - 1, page))
|
||||
if (nextPage === currentPage.value) return
|
||||
pageTransitionDirection.value =
|
||||
nextPage > currentPage.value ? 'forward' : 'backward'
|
||||
currentPage.value = nextPage
|
||||
}
|
||||
|
||||
function startPageSwipe(event: PointerEvent): void {
|
||||
if ((event.target as HTMLElement).closest('button, input')) return
|
||||
pagePointerStart = event.clientX
|
||||
pagePointerId = event.pointerId
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function finishPageSwipe(event: PointerEvent): void {
|
||||
if (pagePointerId !== event.pointerId) return
|
||||
const distance = event.clientX - pagePointerStart
|
||||
if (Math.abs(distance) > 42) {
|
||||
goToPage(currentPage.value + (distance < 0 ? 1 : -1))
|
||||
}
|
||||
pagePointerId = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-folder-layer">
|
||||
<button
|
||||
class="home-folder-backdrop"
|
||||
type="button"
|
||||
:aria-label="phone.t('Home.folders.close')"
|
||||
@click="emit('close')"
|
||||
></button>
|
||||
<section
|
||||
class="home-folder-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="folderName"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="home-folder-heading">
|
||||
<button
|
||||
class="home-folder-title"
|
||||
type="button"
|
||||
:aria-label="phone.t('Home.folders.rename')"
|
||||
@click="openRename"
|
||||
>
|
||||
{{ folderName }}
|
||||
</button>
|
||||
<SkyLink
|
||||
class="home-folder-edit"
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Home.folders.rename')"
|
||||
type="button"
|
||||
@click="openRename"
|
||||
>
|
||||
<Pencil :size="18" :stroke-width="2.2" />
|
||||
</SkyLink>
|
||||
</div>
|
||||
<div
|
||||
class="home-folder-panel"
|
||||
:class="{ 'home-folder-panel--dragging': draggingIndex !== null }"
|
||||
@pointerdown="startPageSwipe"
|
||||
@pointerup="finishPageSwipe"
|
||||
@pointercancel="pagePointerId = null"
|
||||
>
|
||||
<div class="home-folder-page-viewport">
|
||||
<Transition :name="pageTransitionName">
|
||||
<div :key="currentPage" class="home-folder-page-grid">
|
||||
<AppIcon
|
||||
v-for="entry in visibleApps"
|
||||
:key="entry.app.id"
|
||||
:app="entry.app"
|
||||
:data-folder-app-index="entry.index"
|
||||
:edit-mode="editMode"
|
||||
@dragcancel="stopFolderAppDrag"
|
||||
@dragend="finishFolderAppDrag"
|
||||
@dragstart="startFolderAppDrag(entry.index)"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<nav
|
||||
v-if="pageCount > 1"
|
||||
class="home-folder-pages"
|
||||
:aria-label="phone.t('Home.folders.pages')"
|
||||
>
|
||||
<button
|
||||
v-for="page in pageCount"
|
||||
:key="page"
|
||||
type="button"
|
||||
:class="{ active: currentPage === page - 1 }"
|
||||
:aria-label="`${phone.t('Home.page')} ${page}`"
|
||||
@click="goToPage(page - 1)"
|
||||
></button>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SkySheet
|
||||
class="home-folder-rename"
|
||||
:opened="renameOpened"
|
||||
:aria-label="phone.t('Home.folders.rename')"
|
||||
@backdropclick="renameOpened = false"
|
||||
@escape="renameOpened = false"
|
||||
>
|
||||
<form class="home-folder-rename__content" @submit.prevent="saveRename">
|
||||
<span class="home-folder-rename__handle" aria-hidden="true"></span>
|
||||
<header>
|
||||
<div>
|
||||
<small>{{ phone.t('Home.folders.name') }}</small>
|
||||
<h2>{{ phone.t('Home.folders.rename') }}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="home-folder-rename__fields">
|
||||
<SkyField
|
||||
v-model="renameDraft"
|
||||
:aria-label="phone.t('Home.folders.name')"
|
||||
autocomplete="off"
|
||||
clear-button
|
||||
:clear-label="phone.t('Common.clear')"
|
||||
:maxlength="HOME_FOLDER_NAME_MAX_LENGTH"
|
||||
outline
|
||||
:placeholder="phone.t('Home.folders.defaultName')"
|
||||
/>
|
||||
</ul>
|
||||
<div class="home-folder-rename__actions">
|
||||
<SkyButton
|
||||
block
|
||||
large
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="renameOpened = false"
|
||||
>
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</SkyButton>
|
||||
<SkyButton block large rounded type="submit">
|
||||
{{ phone.t('Common.save') }}
|
||||
</SkyButton>
|
||||
</div>
|
||||
</form>
|
||||
</SkySheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-folder-layer {
|
||||
--sky-app-accent: #0a84ff;
|
||||
--sky-bg: #08080a;
|
||||
--sky-surface: #1c1c1e;
|
||||
--sky-surface-muted: #2c2c2e;
|
||||
--sky-text: #f5f5f7;
|
||||
--sky-muted: #98989f;
|
||||
--sky-hairline: rgb(255 255 255 / 12%);
|
||||
position: absolute;
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
color: #fff;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.home-folder-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: rgb(8 12 24 / 38%);
|
||||
}
|
||||
|
||||
.home-folder-dialog {
|
||||
position: absolute;
|
||||
top: 108px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.home-folder-heading {
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.home-folder-title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 2px 12px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 28px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 2px 8px rgb(0 0 0 / 38%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-folder-title:focus-visible,
|
||||
.home-folder-edit:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.home-folder-edit {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 13%);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 12%);
|
||||
text-shadow: 0 2px 8px rgb(0 0 0 / 38%);
|
||||
}
|
||||
|
||||
.home-folder-edit:active {
|
||||
background: rgb(255 255 255 / 22%);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.home-folder-panel {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 342px;
|
||||
padding: 25px 22px 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 25%);
|
||||
border-radius: 28px;
|
||||
background: rgb(115 135 176 / 72%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 18%),
|
||||
0 18px 50px rgb(0 0 0 / 26%);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.home-folder-panel--dragging {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.home-folder-page-viewport {
|
||||
position: relative;
|
||||
min-height: 285px;
|
||||
margin-top: -8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-folder-panel--dragging .home-folder-page-viewport {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.home-folder-page-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(3, 79px);
|
||||
gap: 13px 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.home-folder-page-forward-enter-active,
|
||||
.home-folder-page-forward-leave-active,
|
||||
.home-folder-page-backward-enter-active,
|
||||
.home-folder-page-backward-leave-active {
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 190ms ease,
|
||||
transform 290ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.home-folder-page-forward-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(48px) scale(0.985);
|
||||
}
|
||||
|
||||
.home-folder-page-forward-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-48px) scale(0.985);
|
||||
}
|
||||
|
||||
.home-folder-page-backward-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-48px) scale(0.985);
|
||||
}
|
||||
|
||||
.home-folder-page-backward-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(48px) scale(0.985);
|
||||
}
|
||||
|
||||
.home-folder-page-grid :deep(.app-icon-item) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-folder-page-grid :deep(.app-icon-item--dragging) {
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.home-folder-pages {
|
||||
min-height: 20px;
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-folder-pages button {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 38%);
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.home-folder-pages button.active {
|
||||
background: #fff;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
|
||||
.home-folder-rename {
|
||||
--sky-overlay-layer: 90;
|
||||
}
|
||||
|
||||
.home-folder-rename :deep(.sky-sheet__panel) {
|
||||
overflow: hidden;
|
||||
border-radius: 26px 26px 0 0;
|
||||
background: rgb(28 28 30 / 98%);
|
||||
}
|
||||
|
||||
.home-folder-rename__content {
|
||||
padding: 9px 16px calc(20px + var(--sky-safe-area-bottom));
|
||||
}
|
||||
|
||||
.home-folder-rename__handle {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 5px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 28%);
|
||||
}
|
||||
|
||||
.home-folder-rename__content header {
|
||||
margin: 0 2px 14px;
|
||||
}
|
||||
|
||||
.home-folder-rename__content header small {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
color: var(--sky-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.home-folder-rename__content h2 {
|
||||
margin: 0;
|
||||
color: var(--sky-text);
|
||||
font-size: 21px;
|
||||
line-height: 27px;
|
||||
}
|
||||
|
||||
.home-folder-rename__fields {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.home-folder-rename__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-folder-layer *,
|
||||
.home-folder-layer *::before,
|
||||
.home-folder-layer *::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { removeHomeApp } from '@/utils/homeLayout'
|
||||
import { getHomeFolder, removeHomeApp } from '@/utils/homeLayout'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
phone: {
|
||||
@@ -160,6 +160,43 @@ describe('app store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('persists the complete folder lifecycle through store actions', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
const clockIndex = apps.homeLayout.grid.indexOf('clock')
|
||||
const mailIndex = apps.homeLayout.grid.indexOf('mail')
|
||||
const folderId = apps.createHomeFolder(
|
||||
'grid',
|
||||
notesIndex,
|
||||
'grid',
|
||||
clockIndex,
|
||||
'Utilities',
|
||||
)
|
||||
|
||||
expect(folderId).toBeTruthy()
|
||||
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
|
||||
'clock',
|
||||
'notes',
|
||||
])
|
||||
expect(apps.addHomeAppToFolder('grid', mailIndex, folderId!)).toBe(true)
|
||||
apps.moveHomeFolderApp(folderId!, 2, 0)
|
||||
apps.renameHomeFolder(folderId!, 'Work')
|
||||
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
|
||||
apps: ['mail', 'notes', 'clock'],
|
||||
name: 'Work',
|
||||
})
|
||||
|
||||
const emptyIndex = apps.homeLayout.grid.indexOf(null)
|
||||
expect(
|
||||
apps.extractHomeFolderApp(folderId!, 0, 'grid', emptyIndex),
|
||||
).toBe(true)
|
||||
expect(apps.homeLayout.grid[emptyIndex]).toBe('mail')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('does not commit an installation to a different phone', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -12,11 +12,16 @@ import {
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
addHomeAppToFolder,
|
||||
addHomePage,
|
||||
createHomeFolder,
|
||||
createDefaultHomeLayout,
|
||||
deleteHomePage,
|
||||
extractHomeFolderApp,
|
||||
moveHomeFolderApp,
|
||||
moveHomeApp,
|
||||
parseHomeLayout,
|
||||
renameHomeFolder,
|
||||
removeHomeApp,
|
||||
restoreHomeApp,
|
||||
type HomeArea,
|
||||
@@ -192,7 +197,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' &&
|
||||
(isPhoneAppId(id) ||
|
||||
(layoutVersion === 3 && isValidExternalPhoneAppId(id))),
|
||||
((layoutVersion === 3 ||
|
||||
layoutVersion === 4 ||
|
||||
layoutVersion === 5) &&
|
||||
isValidExternalPhoneAppId(id))),
|
||||
)
|
||||
: []
|
||||
const installedIds = [
|
||||
@@ -218,7 +226,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
for (const [appId, count] of Object.entries(data.launchCounts)) {
|
||||
if (
|
||||
(isPhoneAppId(appId) ||
|
||||
(layoutVersion === 3 && isValidExternalPhoneAppId(appId))) &&
|
||||
((layoutVersion === 3 ||
|
||||
layoutVersion === 4 ||
|
||||
layoutVersion === 5) &&
|
||||
isValidExternalPhoneAppId(appId))) &&
|
||||
typeof count === 'number' &&
|
||||
Number.isFinite(count) &&
|
||||
count > 0
|
||||
@@ -279,6 +290,83 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
this.persist()
|
||||
},
|
||||
createHomeFolder(
|
||||
from: HomeArea,
|
||||
sourceIndex: number,
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
name: string,
|
||||
): string | null {
|
||||
const folderId = `folder-${globalThis.crypto.randomUUID()}`
|
||||
const next = createHomeFolder(
|
||||
this.homeLayout,
|
||||
from,
|
||||
sourceIndex,
|
||||
to,
|
||||
targetIndex,
|
||||
folderId,
|
||||
name,
|
||||
)
|
||||
if (next === this.homeLayout) return null
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
return folderId
|
||||
},
|
||||
addHomeAppToFolder(
|
||||
from: HomeArea,
|
||||
sourceIndex: number,
|
||||
folderId: string,
|
||||
): boolean {
|
||||
const next = addHomeAppToFolder(
|
||||
this.homeLayout,
|
||||
from,
|
||||
sourceIndex,
|
||||
folderId,
|
||||
)
|
||||
if (next === this.homeLayout) return false
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
renameHomeFolder(folderId: string, name: string): void {
|
||||
const next = renameHomeFolder(this.homeLayout, folderId, name)
|
||||
if (next === this.homeLayout) return
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
},
|
||||
moveHomeFolderApp(
|
||||
folderId: string,
|
||||
sourceIndex: number,
|
||||
targetIndex: number,
|
||||
): void {
|
||||
const next = moveHomeFolderApp(
|
||||
this.homeLayout,
|
||||
folderId,
|
||||
sourceIndex,
|
||||
targetIndex,
|
||||
)
|
||||
if (next === this.homeLayout) return
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
},
|
||||
extractHomeFolderApp(
|
||||
folderId: string,
|
||||
sourceIndex: number,
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): boolean {
|
||||
const next = extractHomeFolderApp(
|
||||
this.homeLayout,
|
||||
folderId,
|
||||
sourceIndex,
|
||||
to,
|
||||
targetIndex,
|
||||
)
|
||||
if (next === this.homeLayout) return false
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (isProtectedHomeApp(appId)) return
|
||||
|
||||
|
||||
@@ -3765,6 +3765,13 @@ const defaultLocales: LocaleTree = {
|
||||
removedFromHome: 'Removed from Home Screen',
|
||||
page: 'Page',
|
||||
pages: 'Home screen pages',
|
||||
folders: {
|
||||
defaultName: 'Folder',
|
||||
close: 'Close folder',
|
||||
rename: 'Rename Folder',
|
||||
name: 'Folder name',
|
||||
pages: 'Folder pages',
|
||||
},
|
||||
groups: {
|
||||
suggestions: 'Suggestions',
|
||||
recentlyAdded: 'Recently Added',
|
||||
|
||||
@@ -16,6 +16,30 @@
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.sky-sheet-rise-enter-active .sky-overlay-backdrop,
|
||||
.sky-sheet-rise-leave-active .sky-overlay-backdrop {
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.sky-sheet-rise-enter-active .sky-sheet__panel,
|
||||
.sky-sheet-rise-leave-active .sky-sheet__panel {
|
||||
transition:
|
||||
opacity 220ms ease,
|
||||
transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.sky-sheet-rise-enter-from .sky-overlay-backdrop,
|
||||
.sky-sheet-rise-leave-to .sky-overlay-backdrop {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sky-sheet-rise-enter-from .sky-sheet__panel,
|
||||
.sky-sheet-rise-leave-to .sky-sheet__panel {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.sky-sheet__panel,
|
||||
.sky-action-sheet__panel {
|
||||
position: absolute;
|
||||
@@ -264,3 +288,12 @@
|
||||
.sky-messagebar textarea:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sky-sheet-rise-enter-active .sky-overlay-backdrop,
|
||||
.sky-sheet-rise-leave-active .sky-overlay-backdrop,
|
||||
.sky-sheet-rise-enter-active .sky-sheet__panel,
|
||||
.sky-sheet-rise-leave-active .sky-sheet__panel {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,27 +54,33 @@ useOverlayFocusTrap({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="opened" ref="root" v-bind="$attrs" class="sky-sheet">
|
||||
<div
|
||||
class="sky-overlay-backdrop"
|
||||
aria-hidden="true"
|
||||
@click="emit('backdropclick')"
|
||||
></div>
|
||||
<div
|
||||
ref="panel"
|
||||
class="sky-sheet__panel"
|
||||
:role="inferredRole"
|
||||
:aria-modal="
|
||||
inferredRole === 'dialog' || inferredRole === 'alertdialog'
|
||||
? ariaModal
|
||||
: undefined
|
||||
"
|
||||
:aria-label="inferredRole ? ariaLabel : undefined"
|
||||
:aria-labelledby="inferredRole ? ariaLabelledby : undefined"
|
||||
:aria-describedby="inferredRole ? ariaDescribedby : undefined"
|
||||
:tabindex="tabindex"
|
||||
>
|
||||
<slot />
|
||||
<Transition
|
||||
name="sky-sheet-rise"
|
||||
:duration="{ enter: 320, leave: 320 }"
|
||||
appear
|
||||
>
|
||||
<div v-if="opened" ref="root" v-bind="$attrs" class="sky-sheet">
|
||||
<div
|
||||
class="sky-overlay-backdrop"
|
||||
aria-hidden="true"
|
||||
@click="emit('backdropclick')"
|
||||
></div>
|
||||
<div
|
||||
ref="panel"
|
||||
class="sky-sheet__panel"
|
||||
:role="inferredRole"
|
||||
:aria-modal="
|
||||
inferredRole === 'dialog' || inferredRole === 'alertdialog'
|
||||
? ariaModal
|
||||
: undefined
|
||||
"
|
||||
:aria-label="inferredRole ? ariaLabel : undefined"
|
||||
:aria-labelledby="inferredRole ? ariaLabelledby : undefined"
|
||||
:aria-describedby="inferredRole ? ariaDescribedby : undefined"
|
||||
:tabindex="tabindex"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
addHomeAppToFolder,
|
||||
addHomePage,
|
||||
createHomeFolder,
|
||||
createDefaultHomeLayout,
|
||||
deleteHomePage,
|
||||
extractHomeFolderApp,
|
||||
getHomeFolder,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
homeKeyboardTarget,
|
||||
isHomeFolder,
|
||||
MAX_HOME_GRID_PAGES,
|
||||
moveHomeFolderApp,
|
||||
moveHomeApp,
|
||||
parseHomeLayout,
|
||||
renameHomeFolder,
|
||||
removeHomeApp,
|
||||
restoreHomeApp,
|
||||
type HomeLayout,
|
||||
@@ -35,7 +42,7 @@ describe('home layout', () => {
|
||||
null,
|
||||
])
|
||||
expect(layout.dock).toEqual(['phone', 'messages', 'clock', null])
|
||||
expect(layout.version).toBe(3)
|
||||
expect(layout.version).toBe(5)
|
||||
})
|
||||
|
||||
it('migrates compact persisted arrays and appends newly installed apps', () => {
|
||||
@@ -52,12 +59,12 @@ describe('home layout', () => {
|
||||
expect(layout.dock).toEqual(['messages', null, null, null])
|
||||
expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null])
|
||||
expect(layout.hidden).toEqual(['phone'])
|
||||
expect(layout.version).toBe(3)
|
||||
expect(layout.version).toBe(5)
|
||||
})
|
||||
|
||||
it('preserves explicit gaps in versioned layouts', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: HOME_GRID_PAGE_SIZE },
|
||||
{ length: 20 },
|
||||
() => null,
|
||||
)
|
||||
grid[0] = 'phone'
|
||||
@@ -82,7 +89,7 @@ describe('home layout', () => {
|
||||
|
||||
it('keeps valid custom-app tombstones in version 3 layouts', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: HOME_GRID_PAGE_SIZE },
|
||||
{ length: 20 },
|
||||
() => null,
|
||||
)
|
||||
grid[6] = 'temporarily-missing' as HomeLayout['hidden'][number]
|
||||
@@ -99,12 +106,35 @@ describe('home layout', () => {
|
||||
)
|
||||
|
||||
expect(layout.grid[6]).toBe('temporarily-missing')
|
||||
expect(layout.version).toBe(3)
|
||||
expect(layout.version).toBe(5)
|
||||
})
|
||||
|
||||
it('expands version 3 capacity without compacting persisted gaps', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from({ length: 40 }, () => null)
|
||||
grid[19] = 'mail'
|
||||
grid[20] = 'notes'
|
||||
|
||||
const layout = parseHomeLayout(
|
||||
{
|
||||
dock: ['phone', null, null, null],
|
||||
grid,
|
||||
hidden: [],
|
||||
version: 3,
|
||||
},
|
||||
defaults,
|
||||
[...installed],
|
||||
)
|
||||
|
||||
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
|
||||
expect(layout.grid[19]).toBe('mail')
|
||||
expect(layout.grid[20]).toBe('notes')
|
||||
expect(layout.grid.slice(21, 24)).toEqual([null, null, null])
|
||||
expect(layout.version).toBe(5)
|
||||
})
|
||||
|
||||
it('preserves independently positioned shortcuts for the same app', () => {
|
||||
const grid: HomeLayout['grid'] = Array.from(
|
||||
{ length: HOME_GRID_PAGE_SIZE },
|
||||
{ length: 20 },
|
||||
() => null,
|
||||
)
|
||||
grid[0] = 'phone'
|
||||
@@ -144,33 +174,33 @@ describe('home layout', () => {
|
||||
expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull()
|
||||
})
|
||||
|
||||
it('shifts occupied grid slots instead of replacing their apps', () => {
|
||||
it('swaps occupied grid slots without moving unrelated apps', () => {
|
||||
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
|
||||
expect(reordered.grid.slice(0, 5)).toEqual([
|
||||
'mail',
|
||||
'phone',
|
||||
'messages',
|
||||
'phone',
|
||||
'clock',
|
||||
'notes',
|
||||
])
|
||||
})
|
||||
|
||||
it('shifts an occupied dock slot toward its gap', () => {
|
||||
it('swaps an occupied dock slot with the exact grid source', () => {
|
||||
const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2)
|
||||
|
||||
expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock'])
|
||||
expect(docked.grid[2]).toBeNull()
|
||||
expect(docked.dock).toEqual(['phone', 'messages', 'mail', null])
|
||||
expect(docked.grid[2]).toBe('clock')
|
||||
})
|
||||
|
||||
it('moves an app displaced from a full dock into the source slot', () => {
|
||||
it('swaps with an app in a full dock without shifting the dock', () => {
|
||||
const layout: HomeLayout = {
|
||||
...defaults,
|
||||
dock: ['phone', 'messages', 'clock', 'notes'],
|
||||
}
|
||||
const docked = moveHomeApp(layout, 'grid', 2, 'dock', 1)
|
||||
|
||||
expect(docked.dock).toEqual(['phone', 'mail', 'messages', 'clock'])
|
||||
expect(docked.grid[2]).toBe('notes')
|
||||
expect(docked.dock).toEqual(['phone', 'mail', 'clock', 'notes'])
|
||||
expect(docked.grid[2]).toBe('messages')
|
||||
})
|
||||
|
||||
it('moves shortcuts between the dock and grid independently', () => {
|
||||
@@ -218,4 +248,114 @@ describe('home layout', () => {
|
||||
expect(deleted.grid).toContain('phone')
|
||||
expect(deleteHomePage(deleted, 1)).toBe(deleted)
|
||||
})
|
||||
|
||||
it('creates, renames, and persists a folder without moving other slots', () => {
|
||||
const folderLayout = createHomeFolder(
|
||||
defaults,
|
||||
'grid',
|
||||
2,
|
||||
'grid',
|
||||
4,
|
||||
'folder-work-123456',
|
||||
'Work',
|
||||
)
|
||||
const folder = folderLayout.grid[4]
|
||||
|
||||
expect(folderLayout.grid[2]).toBeNull()
|
||||
expect(isHomeFolder(folder) && folder.apps).toEqual(['notes', 'mail'])
|
||||
expect(folderLayout.grid[0]).toBe('phone')
|
||||
|
||||
const renamed = renameHomeFolder(
|
||||
folderLayout,
|
||||
'folder-work-123456',
|
||||
' Dienstprogramme ',
|
||||
)
|
||||
expect(getHomeFolder(renamed, 'folder-work-123456')?.name).toBe(
|
||||
'Dienstprogramme',
|
||||
)
|
||||
|
||||
const parsed = parseHomeLayout(renamed, defaults, [...installed])
|
||||
expect(parsed.version).toBe(5)
|
||||
expect(getHomeFolder(parsed, 'folder-work-123456')).toEqual({
|
||||
apps: ['notes', 'mail'],
|
||||
id: 'folder-work-123456',
|
||||
name: 'Dienstprogramme',
|
||||
type: 'folder',
|
||||
})
|
||||
})
|
||||
|
||||
it('adds apps to a folder and swaps apps inside its 3x3 pages', () => {
|
||||
const folderLayout = createHomeFolder(
|
||||
defaults,
|
||||
'grid',
|
||||
2,
|
||||
'grid',
|
||||
4,
|
||||
'folder-tools-123456',
|
||||
'Tools',
|
||||
)
|
||||
const expanded = addHomeAppToFolder(
|
||||
folderLayout,
|
||||
'grid',
|
||||
3,
|
||||
'folder-tools-123456',
|
||||
)
|
||||
expect(expanded.grid[3]).toBeNull()
|
||||
expect(getHomeFolder(expanded, 'folder-tools-123456')?.apps).toEqual([
|
||||
'notes',
|
||||
'mail',
|
||||
'clock',
|
||||
])
|
||||
|
||||
const reordered = moveHomeFolderApp(
|
||||
expanded,
|
||||
'folder-tools-123456',
|
||||
2,
|
||||
0,
|
||||
)
|
||||
expect(getHomeFolder(reordered, 'folder-tools-123456')?.apps).toEqual([
|
||||
'clock',
|
||||
'mail',
|
||||
'notes',
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts a folder app into an exact gap and dissolves one-app folders', () => {
|
||||
const folderLayout = createHomeFolder(
|
||||
defaults,
|
||||
'grid',
|
||||
2,
|
||||
'grid',
|
||||
4,
|
||||
'folder-social-123456',
|
||||
'Social',
|
||||
)
|
||||
const extracted = extractHomeFolderApp(
|
||||
folderLayout,
|
||||
'folder-social-123456',
|
||||
1,
|
||||
'grid',
|
||||
12,
|
||||
)
|
||||
|
||||
expect(extracted.grid[12]).toBe('mail')
|
||||
expect(extracted.grid[4]).toBe('notes')
|
||||
expect(getHomeFolder(extracted, 'folder-social-123456')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes hidden apps from folders and dissolves the folder when needed', () => {
|
||||
const folderLayout = createHomeFolder(
|
||||
defaults,
|
||||
'grid',
|
||||
2,
|
||||
'grid',
|
||||
4,
|
||||
'folder-mixed-123456',
|
||||
'Mixed',
|
||||
)
|
||||
const removed = removeHomeApp(folderLayout, 'mail')
|
||||
|
||||
expect(removed.grid[4]).toBe('notes')
|
||||
expect(removed.hidden).toContain('mail')
|
||||
})
|
||||
})
|
||||
|
||||
+318
-100
@@ -3,23 +3,50 @@ import type { ReorderDirection } from '@/utils/keyboard'
|
||||
|
||||
export const HOME_DOCK_CAPACITY = 4
|
||||
export const HOME_GRID_COLUMNS = 4
|
||||
export const HOME_GRID_PAGE_SIZE = 20
|
||||
export const HOME_GRID_PAGE_SIZE = 24
|
||||
export const HOME_FOLDER_PAGE_SIZE = 9
|
||||
export const HOME_FOLDER_NAME_MAX_LENGTH = 32
|
||||
export const MAX_HOME_GRID_PAGES = 5
|
||||
const LEGACY_HOME_GRID_PAGE_SIZE = 20
|
||||
|
||||
export type HomeArea = 'dock' | 'grid'
|
||||
export type HomeSlot = LaunchablePhoneAppId | null
|
||||
|
||||
export type HomeFolder = {
|
||||
apps: LaunchablePhoneAppId[]
|
||||
id: string
|
||||
name: string
|
||||
type: 'folder'
|
||||
}
|
||||
|
||||
export type HomeItem = HomeFolder | LaunchablePhoneAppId
|
||||
export type HomeSlot = HomeItem | null
|
||||
|
||||
export type HomeLayout = {
|
||||
dock: HomeSlot[]
|
||||
grid: HomeSlot[]
|
||||
hidden: LaunchablePhoneAppId[]
|
||||
version: 3
|
||||
version: 5
|
||||
}
|
||||
|
||||
export function isHomeFolder(value: unknown): value is HomeFolder {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const folder = value as Partial<HomeFolder>
|
||||
return (
|
||||
folder.type === 'folder' &&
|
||||
typeof folder.id === 'string' &&
|
||||
typeof folder.name === 'string' &&
|
||||
Array.isArray(folder.apps)
|
||||
)
|
||||
}
|
||||
|
||||
function isPersistableAppId(value: unknown): value is LaunchablePhoneAppId {
|
||||
return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{1,63}$/.test(value)
|
||||
}
|
||||
|
||||
function isPersistableFolderId(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^folder-[a-z0-9-]{6,80}$/.test(value)
|
||||
}
|
||||
|
||||
function readAppIds(
|
||||
value: unknown,
|
||||
availableIds: Set<LaunchablePhoneAppId>,
|
||||
@@ -50,74 +77,107 @@ function getGridCapacity(itemCount: number): number {
|
||||
)
|
||||
}
|
||||
|
||||
function migrateLegacyGrid(value: unknown): unknown[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.slice(0, LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
|
||||
}
|
||||
|
||||
function cloneItem(item: HomeSlot): HomeSlot {
|
||||
return isHomeFolder(item) ? { ...item, apps: [...item.apps] } : item
|
||||
}
|
||||
|
||||
function cloneLayout(layout: HomeLayout): HomeLayout {
|
||||
return {
|
||||
dock: layout.dock.map(cloneItem),
|
||||
grid: layout.grid.map(cloneItem),
|
||||
hidden: [...layout.hidden],
|
||||
version: 5,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFolder(folder: HomeFolder): HomeSlot {
|
||||
if (folder.apps.length === 0) return null
|
||||
if (folder.apps.length === 1) return folder.apps[0]
|
||||
return folder
|
||||
}
|
||||
|
||||
function readItem(
|
||||
value: unknown,
|
||||
availableIds: Set<LaunchablePhoneAppId>,
|
||||
folderIds: Set<string>,
|
||||
): HomeSlot {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
availableIds.has(value as LaunchablePhoneAppId)
|
||||
) {
|
||||
return value as LaunchablePhoneAppId
|
||||
}
|
||||
if (!isHomeFolder(value) || !isPersistableFolderId(value.id)) return null
|
||||
if (folderIds.has(value.id)) return null
|
||||
|
||||
const apps = readAppIds(value.apps, availableIds)
|
||||
if (apps.length === 0) return null
|
||||
if (apps.length === 1) return apps[0]
|
||||
folderIds.add(value.id)
|
||||
return {
|
||||
apps,
|
||||
id: value.id,
|
||||
name: value.name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH),
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
function readSlots(
|
||||
value: unknown,
|
||||
availableIds: Set<LaunchablePhoneAppId>,
|
||||
length: number,
|
||||
folderIds: Set<string>,
|
||||
): HomeSlot[] {
|
||||
const slots = createSlots(length)
|
||||
if (!Array.isArray(value)) return slots
|
||||
|
||||
for (let index = 0; index < Math.min(value.length, length); index += 1) {
|
||||
const valueId = value[index]
|
||||
if (
|
||||
typeof valueId === 'string' &&
|
||||
availableIds.has(valueId as LaunchablePhoneAppId)
|
||||
) {
|
||||
slots[index] = valueId as LaunchablePhoneAppId
|
||||
}
|
||||
slots[index] = readItem(value[index], availableIds, folderIds)
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
function placeInFirstEmptySlot(
|
||||
slots: HomeSlot[],
|
||||
appId: LaunchablePhoneAppId,
|
||||
): void {
|
||||
function placeInFirstEmptySlot(slots: HomeSlot[], item: HomeItem): void {
|
||||
const emptyIndex = slots.indexOf(null)
|
||||
if (emptyIndex !== -1) {
|
||||
slots[emptyIndex] = appId
|
||||
slots[emptyIndex] = cloneItem(item)
|
||||
return
|
||||
}
|
||||
|
||||
slots.push(...createSlots(HOME_GRID_PAGE_SIZE))
|
||||
slots[slots.length - HOME_GRID_PAGE_SIZE] = appId
|
||||
slots[slots.length - HOME_GRID_PAGE_SIZE] = cloneItem(item)
|
||||
}
|
||||
|
||||
function insertIntoSlot(
|
||||
slots: HomeSlot[],
|
||||
targetIndex: number,
|
||||
appId: LaunchablePhoneAppId,
|
||||
): HomeSlot {
|
||||
if (slots[targetIndex] === null) {
|
||||
slots[targetIndex] = appId
|
||||
return null
|
||||
}
|
||||
function itemContainsApp(item: HomeSlot, appId: LaunchablePhoneAppId): boolean {
|
||||
return isHomeFolder(item) ? item.apps.includes(appId) : item === appId
|
||||
}
|
||||
|
||||
const emptyAfter = slots.indexOf(null, targetIndex + 1)
|
||||
if (emptyAfter !== -1) {
|
||||
for (let index = emptyAfter; index > targetIndex; index -= 1) {
|
||||
slots[index] = slots[index - 1]
|
||||
}
|
||||
slots[targetIndex] = appId
|
||||
return null
|
||||
function folderLocation(
|
||||
layout: HomeLayout,
|
||||
folderId: string,
|
||||
): { area: HomeArea; index: number } | null {
|
||||
for (const area of ['grid', 'dock'] as const) {
|
||||
const index = layout[area].findIndex(
|
||||
(item) => isHomeFolder(item) && item.id === folderId,
|
||||
)
|
||||
if (index !== -1) return { area, index }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const emptyBefore = slots.lastIndexOf(null, targetIndex - 1)
|
||||
if (emptyBefore !== -1) {
|
||||
for (let index = emptyBefore; index < targetIndex; index += 1) {
|
||||
slots[index] = slots[index + 1]
|
||||
}
|
||||
slots[targetIndex] = appId
|
||||
return null
|
||||
}
|
||||
|
||||
const displacedApp = slots.at(-1) ?? null
|
||||
for (let index = slots.length - 1; index > targetIndex; index -= 1) {
|
||||
slots[index] = slots[index - 1]
|
||||
}
|
||||
slots[targetIndex] = appId
|
||||
return displacedApp
|
||||
export function getHomeFolder(
|
||||
layout: HomeLayout,
|
||||
folderId: string,
|
||||
): HomeFolder | null {
|
||||
const location = folderLocation(layout, folderId)
|
||||
if (!location) return null
|
||||
const item = layout[location.area][location.index]
|
||||
return isHomeFolder(item) ? item : null
|
||||
}
|
||||
|
||||
export function createDefaultHomeLayout(
|
||||
@@ -140,7 +200,7 @@ export function createDefaultHomeLayout(
|
||||
dock[index] = id
|
||||
}
|
||||
|
||||
return { dock, grid, hidden: [], version: 3 }
|
||||
return { dock, grid, hidden: [], version: 5 }
|
||||
}
|
||||
|
||||
export function parseHomeLayout(
|
||||
@@ -152,18 +212,27 @@ export function parseHomeLayout(
|
||||
|
||||
const source = value as Partial<Record<keyof HomeLayout, unknown>>
|
||||
const availableIds = new Set(installedIds)
|
||||
if (source.version === 3) {
|
||||
if (source.version === 3 || source.version === 4 || source.version === 5) {
|
||||
for (const collection of [source.dock, source.grid, source.hidden]) {
|
||||
if (!Array.isArray(collection)) continue
|
||||
for (const appId of collection) {
|
||||
if (isPersistableAppId(appId)) availableIds.add(appId)
|
||||
for (const item of collection) {
|
||||
if (isPersistableAppId(item)) availableIds.add(item)
|
||||
if (source.version === 5 && isHomeFolder(item)) {
|
||||
for (const appId of item.apps) {
|
||||
if (isPersistableAppId(appId)) availableIds.add(appId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const hidden = readAppIds(source.hidden, availableIds)
|
||||
const hiddenIds = new Set(hidden)
|
||||
const persistedGridLength = Array.isArray(source.grid)
|
||||
? Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
|
||||
const persistedGrid =
|
||||
source.version === 2 || source.version === 3
|
||||
? migrateLegacyGrid(source.grid)
|
||||
: source.grid
|
||||
const persistedGridLength = Array.isArray(persistedGrid)
|
||||
? Math.min(persistedGrid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
|
||||
: 0
|
||||
const gridLength = Math.max(
|
||||
defaults.grid.length,
|
||||
@@ -172,9 +241,15 @@ export function parseHomeLayout(
|
||||
let grid: HomeSlot[]
|
||||
let dock: HomeSlot[]
|
||||
|
||||
if (source.version === 2 || source.version === 3) {
|
||||
grid = readSlots(source.grid, availableIds, gridLength)
|
||||
dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY)
|
||||
if (
|
||||
source.version === 2 ||
|
||||
source.version === 3 ||
|
||||
source.version === 4 ||
|
||||
source.version === 5
|
||||
) {
|
||||
const folderIds = new Set<string>()
|
||||
grid = readSlots(persistedGrid, availableIds, gridLength, folderIds)
|
||||
dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY, folderIds)
|
||||
} else {
|
||||
grid = createSlots(gridLength)
|
||||
for (const id of readAppIds(source.grid, availableIds)) {
|
||||
@@ -188,35 +263,53 @@ export function parseHomeLayout(
|
||||
}
|
||||
}
|
||||
|
||||
grid = grid.map((id) => (id && !hiddenIds.has(id) ? id : null))
|
||||
dock = dock.map((id) => (id && !hiddenIds.has(id) ? id : null))
|
||||
const placedIds = new Set(
|
||||
[...grid, ...dock, ...hidden].filter(
|
||||
(id): id is LaunchablePhoneAppId => id !== null,
|
||||
),
|
||||
)
|
||||
|
||||
for (const id of defaults.grid) {
|
||||
if (id && !placedIds.has(id)) {
|
||||
placeInFirstEmptySlot(grid, id)
|
||||
placedIds.add(id)
|
||||
const removeHidden = (item: HomeSlot): HomeSlot => {
|
||||
if (typeof item === 'string') return hiddenIds.has(item) ? null : item
|
||||
if (!isHomeFolder(item)) return null
|
||||
return normalizeFolder({
|
||||
...item,
|
||||
apps: item.apps.filter((appId) => !hiddenIds.has(appId)),
|
||||
})
|
||||
}
|
||||
grid = grid.map(removeHidden)
|
||||
dock = dock.map(removeHidden)
|
||||
const placedIds = new Set<LaunchablePhoneAppId>(hidden)
|
||||
for (const item of [...grid, ...dock]) {
|
||||
if (typeof item === 'string') placedIds.add(item)
|
||||
if (isHomeFolder(item)) {
|
||||
for (const appId of item.apps) placedIds.add(appId)
|
||||
}
|
||||
}
|
||||
|
||||
return { dock, grid, hidden, version: 3 }
|
||||
for (const item of defaults.grid) {
|
||||
if (typeof item === 'string' && !placedIds.has(item)) {
|
||||
placeInFirstEmptySlot(grid, item)
|
||||
placedIds.add(item)
|
||||
}
|
||||
}
|
||||
|
||||
return { dock, grid, hidden, version: 5 }
|
||||
}
|
||||
|
||||
export function removeHomeApp(
|
||||
layout: HomeLayout,
|
||||
appId: LaunchablePhoneAppId,
|
||||
): HomeLayout {
|
||||
const removeFromItem = (item: HomeSlot): HomeSlot => {
|
||||
if (item === appId) return null
|
||||
if (!isHomeFolder(item)) return item
|
||||
return normalizeFolder({
|
||||
...item,
|
||||
apps: item.apps.filter((folderAppId) => folderAppId !== appId),
|
||||
})
|
||||
}
|
||||
return {
|
||||
dock: layout.dock.map((id) => (id === appId ? null : id)),
|
||||
grid: layout.grid.map((id) => (id === appId ? null : id)),
|
||||
dock: layout.dock.map(removeFromItem),
|
||||
grid: layout.grid.map(removeFromItem),
|
||||
hidden: layout.hidden.includes(appId)
|
||||
? [...layout.hidden]
|
||||
: [...layout.hidden, appId],
|
||||
version: 3,
|
||||
version: 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,14 +317,19 @@ export function restoreHomeApp(
|
||||
layout: HomeLayout,
|
||||
appId: LaunchablePhoneAppId,
|
||||
): HomeLayout {
|
||||
if (layout.grid.includes(appId) || layout.dock.includes(appId)) return layout
|
||||
const grid = [...layout.grid]
|
||||
if (
|
||||
layout.grid.some((item) => itemContainsApp(item, appId)) ||
|
||||
layout.dock.some((item) => itemContainsApp(item, appId))
|
||||
) {
|
||||
return layout
|
||||
}
|
||||
const grid = layout.grid.map(cloneItem)
|
||||
placeInFirstEmptySlot(grid, appId)
|
||||
return {
|
||||
dock: [...layout.dock],
|
||||
dock: layout.dock.map(cloneItem),
|
||||
grid,
|
||||
hidden: layout.hidden.filter((id) => id !== appId),
|
||||
version: 3,
|
||||
version: 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,10 +339,10 @@ export function addHomePage(layout: HomeLayout): HomeLayout {
|
||||
}
|
||||
|
||||
return {
|
||||
dock: [...layout.dock],
|
||||
grid: [...layout.grid, ...createSlots(HOME_GRID_PAGE_SIZE)],
|
||||
dock: layout.dock.map(cloneItem),
|
||||
grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)],
|
||||
hidden: [...layout.hidden],
|
||||
version: 3,
|
||||
version: 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,20 +351,20 @@ export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
|
||||
if (pageCount <= 1 || page < 1 || page > pageCount) return layout
|
||||
|
||||
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
|
||||
const grid = [...layout.grid]
|
||||
const removedApps = grid
|
||||
const grid = layout.grid.map(cloneItem)
|
||||
const removedItems = grid
|
||||
.splice(pageStart, HOME_GRID_PAGE_SIZE)
|
||||
.filter((appId): appId is LaunchablePhoneAppId => appId !== null)
|
||||
if (removedApps.length > grid.filter((appId) => appId === null).length) {
|
||||
.filter((item): item is HomeItem => item !== null)
|
||||
if (removedItems.length > grid.filter((item) => item === null).length) {
|
||||
return layout
|
||||
}
|
||||
for (const appId of removedApps) placeInFirstEmptySlot(grid, appId)
|
||||
for (const item of removedItems) placeInFirstEmptySlot(grid, item)
|
||||
|
||||
return {
|
||||
dock: [...layout.dock],
|
||||
dock: layout.dock.map(cloneItem),
|
||||
grid,
|
||||
hidden: [...layout.hidden],
|
||||
version: 3,
|
||||
version: 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,17 +375,12 @@ export function moveHomeApp(
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): HomeLayout {
|
||||
const next: HomeLayout = {
|
||||
dock: [...layout.dock],
|
||||
grid: [...layout.grid],
|
||||
hidden: [...layout.hidden],
|
||||
version: 3,
|
||||
}
|
||||
const next = cloneLayout(layout)
|
||||
const source = next[from]
|
||||
const target = next[to]
|
||||
const appId = source[sourceIndex]
|
||||
const item = source[sourceIndex]
|
||||
if (
|
||||
!appId ||
|
||||
!item ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= source.length ||
|
||||
targetIndex < 0 ||
|
||||
@@ -296,14 +389,139 @@ export function moveHomeApp(
|
||||
return layout
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
if (sourceIndex === targetIndex) return layout
|
||||
source[sourceIndex] = null
|
||||
insertIntoSlot(source, targetIndex, appId)
|
||||
return next
|
||||
if (from === to && sourceIndex === targetIndex) return layout
|
||||
source[sourceIndex] = cloneItem(target[targetIndex])
|
||||
target[targetIndex] = item
|
||||
return next
|
||||
}
|
||||
|
||||
export function createHomeFolder(
|
||||
layout: HomeLayout,
|
||||
from: HomeArea,
|
||||
sourceIndex: number,
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
folderId: string,
|
||||
name: string,
|
||||
): HomeLayout {
|
||||
if (!isPersistableFolderId(folderId)) return layout
|
||||
if (folderLocation(layout, folderId)) return layout
|
||||
const sourceItem = layout[from][sourceIndex]
|
||||
const targetItem = layout[to][targetIndex]
|
||||
if (
|
||||
typeof sourceItem !== 'string' ||
|
||||
typeof targetItem !== 'string' ||
|
||||
(from === to && sourceIndex === targetIndex)
|
||||
) {
|
||||
return layout
|
||||
}
|
||||
|
||||
source[sourceIndex] = insertIntoSlot(target, targetIndex, appId)
|
||||
const next = cloneLayout(layout)
|
||||
next[from][sourceIndex] = null
|
||||
next[to][targetIndex] = {
|
||||
apps: [targetItem, sourceItem],
|
||||
id: folderId,
|
||||
name: name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH),
|
||||
type: 'folder',
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function addHomeAppToFolder(
|
||||
layout: HomeLayout,
|
||||
from: HomeArea,
|
||||
sourceIndex: number,
|
||||
folderId: string,
|
||||
): HomeLayout {
|
||||
const sourceItem = layout[from][sourceIndex]
|
||||
const location = folderLocation(layout, folderId)
|
||||
if (typeof sourceItem !== 'string' || !location) return layout
|
||||
if (location.area === from && location.index === sourceIndex) return layout
|
||||
|
||||
const next = cloneLayout(layout)
|
||||
const folder = next[location.area][location.index]
|
||||
if (!isHomeFolder(folder)) return layout
|
||||
next[from][sourceIndex] = null
|
||||
folder.apps.push(sourceItem)
|
||||
return next
|
||||
}
|
||||
|
||||
export function renameHomeFolder(
|
||||
layout: HomeLayout,
|
||||
folderId: string,
|
||||
name: string,
|
||||
): HomeLayout {
|
||||
const location = folderLocation(layout, folderId)
|
||||
if (!location) return layout
|
||||
const next = cloneLayout(layout)
|
||||
const folder = next[location.area][location.index]
|
||||
if (!isHomeFolder(folder)) return layout
|
||||
const nextName = name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH)
|
||||
if (folder.name === nextName) return layout
|
||||
folder.name = nextName
|
||||
return next
|
||||
}
|
||||
|
||||
export function moveHomeFolderApp(
|
||||
layout: HomeLayout,
|
||||
folderId: string,
|
||||
sourceIndex: number,
|
||||
targetIndex: number,
|
||||
): HomeLayout {
|
||||
const location = folderLocation(layout, folderId)
|
||||
if (!location) return layout
|
||||
const folder = layout[location.area][location.index]
|
||||
if (
|
||||
!isHomeFolder(folder) ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= folder.apps.length ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= folder.apps.length ||
|
||||
sourceIndex === targetIndex
|
||||
) {
|
||||
return layout
|
||||
}
|
||||
|
||||
const next = cloneLayout(layout)
|
||||
const nextFolder = next[location.area][location.index]
|
||||
if (!isHomeFolder(nextFolder)) return layout
|
||||
const sourceApp = nextFolder.apps[sourceIndex]
|
||||
nextFolder.apps[sourceIndex] = nextFolder.apps[targetIndex]
|
||||
nextFolder.apps[targetIndex] = sourceApp
|
||||
return next
|
||||
}
|
||||
|
||||
export function extractHomeFolderApp(
|
||||
layout: HomeLayout,
|
||||
folderId: string,
|
||||
sourceIndex: number,
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): HomeLayout {
|
||||
const location = folderLocation(layout, folderId)
|
||||
const target = layout[to][targetIndex]
|
||||
if (
|
||||
!location ||
|
||||
target !== null ||
|
||||
(location.area === to && location.index === targetIndex)
|
||||
) {
|
||||
return layout
|
||||
}
|
||||
const folder = layout[location.area][location.index]
|
||||
if (
|
||||
!isHomeFolder(folder) ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= folder.apps.length
|
||||
) {
|
||||
return layout
|
||||
}
|
||||
|
||||
const next = cloneLayout(layout)
|
||||
const nextFolder = next[location.area][location.index]
|
||||
if (!isHomeFolder(nextFolder)) return layout
|
||||
const [appId] = nextFolder.apps.splice(sourceIndex, 1)
|
||||
next[to][targetIndex] = appId
|
||||
next[location.area][location.index] = normalizeFolder(nextFolder)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('widget layout', () => {
|
||||
instance.id.startsWith('wallet-'),
|
||||
)
|
||||
|
||||
expect(added).toMatchObject({ column: 0, page: 2, row: 0, size: 'small' })
|
||||
expect(added).toMatchObject({ column: 0, page: 1, row: 4, size: 'small' })
|
||||
})
|
||||
|
||||
it('moves a widget and reflows a displaced neighbor', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
|
||||
export const WIDGET_GRID_COLUMNS = 4
|
||||
export const WIDGET_HOME_ROWS = 5
|
||||
export const WIDGET_HOME_ROWS = 6
|
||||
export const WIDGET_PAGE_ROWS = 10
|
||||
const MAX_WIDGET_HOME_PAGES = 5
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { kButton, kGlass, kList, kListItem, kSheet } from 'konsta/vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import HomeFolderIcon from '@/components/HomeFolderIcon.vue'
|
||||
import HomeFolderOverlay from '@/components/HomeFolderOverlay.vue'
|
||||
import SpringboardWidgetGrid from '@/components/SpringboardWidgetGrid.vue'
|
||||
import WidgetConfigSheet from '@/components/WidgetConfigSheet.vue'
|
||||
import WidgetPickerSheet from '@/components/WidgetPickerSheet.vue'
|
||||
@@ -16,10 +18,14 @@ import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
|
||||
import {
|
||||
deleteHomePage as previewHomePageDelete,
|
||||
getHomeFolder,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
homeKeyboardTarget,
|
||||
isHomeFolder,
|
||||
MAX_HOME_GRID_PAGES,
|
||||
type HomeArea,
|
||||
type HomeFolder,
|
||||
type HomeItem,
|
||||
} from '@/utils/homeLayout'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import {
|
||||
@@ -37,6 +43,7 @@ const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
|
||||
'utilities',
|
||||
'shopping',
|
||||
]
|
||||
const HOME_FOLDER_HOVER_DURATION = 900
|
||||
const phone = usePhoneStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const widgets = useWidgetsStore()
|
||||
@@ -63,6 +70,9 @@ const draggingHomeApp = ref<{
|
||||
area: HomeArea
|
||||
index: number
|
||||
} | null>(null)
|
||||
const openedFolderId = ref<string | null>(null)
|
||||
const renameFolderOnOpenId = ref<string | null>(null)
|
||||
const folderHoverTarget = ref('')
|
||||
let pointerStart = 0
|
||||
let pointerStartY = 0
|
||||
let pointerStartedAt = 0
|
||||
@@ -75,6 +85,7 @@ let lastWidgetPointer: { clientX: number; clientY: number } | null = null
|
||||
let edgePageTimer: number | undefined
|
||||
let edgePageDirection = 0
|
||||
let edgePageLocked = false
|
||||
let folderHoverTimer: number | undefined
|
||||
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
||||
@@ -87,17 +98,25 @@ const hiddenApps = computed(() =>
|
||||
appStore.homeLayout.hidden.includes(app.id),
|
||||
),
|
||||
)
|
||||
const gridEntries = computed(() =>
|
||||
appStore.homeLayout.grid
|
||||
.map((id, sourceIndex) => ({
|
||||
app: id ? (installedAppsById.value.get(id) ?? null) : null,
|
||||
const gridSlots = computed(() => {
|
||||
const appOccurrences = new Map<string, number>()
|
||||
return appStore.homeLayout.grid.map((item, sourceIndex) => {
|
||||
const appId = typeof item === 'string' ? item : null
|
||||
const folder = isHomeFolder(item) ? item : null
|
||||
const occurrence = appId ? (appOccurrences.get(appId) ?? 0) : 0
|
||||
if (appId) appOccurrences.set(appId, occurrence + 1)
|
||||
return {
|
||||
app: appId ? (installedAppsById.value.get(appId) ?? null) : null,
|
||||
folder,
|
||||
renderKey: folder
|
||||
? `grid-folder-${folder.id}`
|
||||
: appId
|
||||
? `grid-app-${appId}-${occurrence}`
|
||||
: `grid-empty-${sourceIndex}`,
|
||||
sourceIndex,
|
||||
}))
|
||||
.filter(
|
||||
(entry): entry is { app: PhoneAppDefinition; sourceIndex: number } =>
|
||||
entry.app !== null,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
const previewWidgetLayout = computed(() => {
|
||||
const preview = widgetDragPreview.value
|
||||
return preview
|
||||
@@ -111,9 +130,14 @@ const previewWidgetLayout = computed(() => {
|
||||
: widgets.layout
|
||||
})
|
||||
const appPages = computed(() => {
|
||||
const entries = [...gridEntries.value]
|
||||
const slots = [...gridSlots.value]
|
||||
const pages: Array<{
|
||||
cells: Array<{ app: PhoneAppDefinition; sourceIndex: number } | null>
|
||||
cells: Array<{
|
||||
app: PhoneAppDefinition | null
|
||||
folder: HomeFolder | null
|
||||
renderKey: string
|
||||
sourceIndex: number | null
|
||||
}>
|
||||
page: number
|
||||
}> = []
|
||||
const maxWidgetPage = Math.max(
|
||||
@@ -126,8 +150,8 @@ const appPages = computed(() => {
|
||||
)
|
||||
const lastHomePage = Math.max(maxWidgetPage, persistedHomePages)
|
||||
let page = 1
|
||||
let entryIndex = 0
|
||||
while (entryIndex < entries.length || page <= lastHomePage) {
|
||||
let slotIndex = 0
|
||||
while (slotIndex < slots.length || page <= lastHomePage) {
|
||||
const occupied = widgetOccupiedCells(
|
||||
previewWidgetLayout.value.instances,
|
||||
page,
|
||||
@@ -137,21 +161,40 @@ const appPages = computed(() => {
|
||||
occupied.add(cell)
|
||||
}
|
||||
}
|
||||
const cells = Array.from<{
|
||||
app: PhoneAppDefinition
|
||||
sourceIndex: number
|
||||
} | null>({ length: HOME_GRID_PAGE_SIZE }).fill(null)
|
||||
const cells: Array<{
|
||||
app: PhoneAppDefinition | null
|
||||
folder: HomeFolder | null
|
||||
renderKey: string
|
||||
sourceIndex: number | null
|
||||
}> = Array(HOME_GRID_PAGE_SIZE)
|
||||
.fill(null)
|
||||
.map((_, cell) => ({
|
||||
app: null,
|
||||
folder: null,
|
||||
renderKey: `grid-occupied-${page}-${cell}`,
|
||||
sourceIndex: null,
|
||||
}))
|
||||
for (let cell = 0; cell < HOME_GRID_PAGE_SIZE; cell += 1) {
|
||||
if (occupied.has(cell) || entryIndex >= entries.length) continue
|
||||
cells[cell] = entries[entryIndex]
|
||||
entryIndex += 1
|
||||
if (occupied.has(cell) || slotIndex >= slots.length) continue
|
||||
cells[cell] = slots[slotIndex]
|
||||
slotIndex += 1
|
||||
}
|
||||
pages.push({ cells, page })
|
||||
page += 1
|
||||
}
|
||||
return pages.length
|
||||
? pages
|
||||
: [{ cells: Array(HOME_GRID_PAGE_SIZE).fill(null), page: 1 }]
|
||||
: [
|
||||
{
|
||||
cells: Array.from({ length: HOME_GRID_PAGE_SIZE }, (_, sourceIndex) => ({
|
||||
app: null,
|
||||
folder: null,
|
||||
renderKey: `grid-empty-${sourceIndex}`,
|
||||
sourceIndex,
|
||||
})),
|
||||
page: 1,
|
||||
},
|
||||
]
|
||||
})
|
||||
const pageCount = computed(() => appPages.value.length + 2)
|
||||
const libraryPage = computed(() => pageCount.value - 1)
|
||||
@@ -183,9 +226,27 @@ const canDeleteCurrentPage = computed(() => {
|
||||
)
|
||||
})
|
||||
const dockSlots = computed(() =>
|
||||
appStore.homeLayout.dock.map((id) =>
|
||||
id ? (installedAppsById.value.get(id) ?? null) : null,
|
||||
),
|
||||
appStore.homeLayout.dock.map((item, index) => ({
|
||||
app:
|
||||
typeof item === 'string'
|
||||
? (installedAppsById.value.get(item) ?? null)
|
||||
: null,
|
||||
folder: isHomeFolder(item) ? item : null,
|
||||
index,
|
||||
})),
|
||||
)
|
||||
const openedFolder = computed(() =>
|
||||
openedFolderId.value
|
||||
? getHomeFolder(appStore.homeLayout, openedFolderId.value)
|
||||
: null,
|
||||
)
|
||||
const openedFolderApps = computed(() =>
|
||||
openedFolder.value
|
||||
? openedFolder.value.apps.flatMap((appId, index) => {
|
||||
const app = installedAppsById.value.get(appId)
|
||||
return app ? [{ app, index }] : []
|
||||
})
|
||||
: [],
|
||||
)
|
||||
const filteredApps = computed(() => {
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
@@ -567,27 +628,151 @@ async function saveWidgetConfig(
|
||||
if (configured) changePage(configured.page)
|
||||
}
|
||||
|
||||
function targetHomeIndex(page: number, cell: number): number {
|
||||
return Math.min(
|
||||
Math.max(0, appStore.homeLayout.grid.length - 1),
|
||||
(page - 1) * HOME_GRID_PAGE_SIZE + cell,
|
||||
)
|
||||
}
|
||||
|
||||
function startHomeDrag(area: HomeArea, index: number): void {
|
||||
draggingHomeApp.value = { area, index }
|
||||
}
|
||||
|
||||
function clearFolderHover(): void {
|
||||
if (folderHoverTimer !== undefined) window.clearTimeout(folderHoverTimer)
|
||||
folderHoverTimer = undefined
|
||||
folderHoverTarget.value = ''
|
||||
}
|
||||
|
||||
function queueFolderHover(event: PointerEvent): void {
|
||||
const dragged = draggingHomeApp.value
|
||||
if (!dragged) return
|
||||
const sourceItem = appStore.homeLayout[dragged.area][dragged.index]
|
||||
if (typeof sourceItem !== 'string') {
|
||||
clearFolderHover()
|
||||
return
|
||||
}
|
||||
|
||||
const targetElement = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
|
||||
.find(
|
||||
(element) =>
|
||||
element && !element.closest('.app-icon-item--dragging'),
|
||||
)
|
||||
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
||||
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
||||
if (
|
||||
(area !== 'grid' && area !== 'dock') ||
|
||||
!Number.isInteger(targetIndex) ||
|
||||
(area === dragged.area && targetIndex === dragged.index)
|
||||
) {
|
||||
clearFolderHover()
|
||||
return
|
||||
}
|
||||
const targetItem = appStore.homeLayout[area][targetIndex]
|
||||
if (typeof targetItem !== 'string' && !isHomeFolder(targetItem)) {
|
||||
clearFolderHover()
|
||||
return
|
||||
}
|
||||
|
||||
const targetKey = `${area}:${targetIndex}`
|
||||
if (folderHoverTarget.value === targetKey) return
|
||||
clearFolderHover()
|
||||
folderHoverTarget.value = targetKey
|
||||
folderHoverTimer = window.setTimeout(() => {
|
||||
const currentDrag = draggingHomeApp.value
|
||||
if (!currentDrag || folderHoverTarget.value !== targetKey) return
|
||||
const currentSource = appStore.homeLayout[currentDrag.area][currentDrag.index]
|
||||
const currentTarget = appStore.homeLayout[area][targetIndex]
|
||||
if (typeof currentSource !== 'string') return
|
||||
|
||||
let folderId: string | null = null
|
||||
if (typeof currentTarget === 'string') {
|
||||
folderId = appStore.createHomeFolder(
|
||||
currentDrag.area,
|
||||
currentDrag.index,
|
||||
area,
|
||||
targetIndex,
|
||||
phone.t('Home.folders.defaultName'),
|
||||
)
|
||||
if (folderId) renameFolderOnOpenId.value = folderId
|
||||
} else if (isHomeFolder(currentTarget)) {
|
||||
folderId = appStore.addHomeAppToFolder(
|
||||
currentDrag.area,
|
||||
currentDrag.index,
|
||||
currentTarget.id,
|
||||
)
|
||||
? currentTarget.id
|
||||
: null
|
||||
}
|
||||
if (folderId) {
|
||||
draggingHomeApp.value = null
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
clearFolderHover()
|
||||
}, HOME_FOLDER_HOVER_DURATION)
|
||||
}
|
||||
|
||||
function moveHomeDrag(event: PointerEvent): void {
|
||||
if (draggingHomeApp.value?.area === 'grid') {
|
||||
queueEdgePageTurn(event, 'app')
|
||||
}
|
||||
queueFolderHover(event)
|
||||
}
|
||||
|
||||
async function animateHomeItemDrop(
|
||||
item: HomeItem,
|
||||
area: HomeArea,
|
||||
index: number,
|
||||
from: DOMRect,
|
||||
): Promise<void> {
|
||||
await nextTick()
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
const appElement = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-item-key]'),
|
||||
).find(
|
||||
(element) =>
|
||||
element.dataset.homeItemKey ===
|
||||
(isHomeFolder(item) ? item.id : `app-${item}`) &&
|
||||
element.dataset.homeArea === area &&
|
||||
Number(element.dataset.homeIndex) === index,
|
||||
)
|
||||
if (!appElement) return
|
||||
|
||||
const target = appElement.getBoundingClientRect()
|
||||
const offsetX = from.left - target.left
|
||||
const offsetY = from.top - target.top
|
||||
if (Math.hypot(offsetX, offsetY) < 1) return
|
||||
|
||||
appElement.style.setProperty('--home-app-drop-x', `${offsetX}px`)
|
||||
appElement.style.setProperty('--home-app-drop-y', `${offsetY}px`)
|
||||
let cleanedUp = false
|
||||
const cleanUpAnimation = (event: AnimationEvent): void => {
|
||||
if (
|
||||
event.target !== appElement ||
|
||||
event.animationName !== 'home-app-drop-settle'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (cleanedUp) return
|
||||
cleanedUp = true
|
||||
appElement.classList.remove('app-icon-item--drop-settling')
|
||||
appElement.style.removeProperty('--home-app-drop-x')
|
||||
appElement.style.removeProperty('--home-app-drop-y')
|
||||
appElement.removeEventListener('animationend', cleanUpAnimation)
|
||||
appElement.removeEventListener('animationcancel', cleanUpAnimation)
|
||||
}
|
||||
appElement.addEventListener('animationend', cleanUpAnimation)
|
||||
appElement.addEventListener('animationcancel', cleanUpAnimation)
|
||||
appElement.classList.add('app-icon-item--drop-settling')
|
||||
}
|
||||
|
||||
function finishHomeDrag(event: PointerEvent): void {
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
const dragged = draggingHomeApp.value
|
||||
if (!dragged) return
|
||||
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
||||
const draggedElement = (event.currentTarget as HTMLElement | null)?.closest(
|
||||
'.app-icon-item',
|
||||
)
|
||||
const dropOrigin = draggedElement?.getBoundingClientRect()
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.find(
|
||||
@@ -619,15 +804,23 @@ function finishHomeDrag(event: PointerEvent): void {
|
||||
}
|
||||
const area = (targetItem?.dataset.homeArea ??
|
||||
targetArea?.dataset.homeArea) as HomeArea | undefined
|
||||
let dropArea = dragged.area
|
||||
let dropIndex = dragged.index
|
||||
if ((area === 'grid' || area === 'dock') && targetItem) {
|
||||
const targetIndex = Number.parseInt(targetItem.dataset.homeIndex ?? '', 10)
|
||||
appStore.moveHomeApp(dragged.area, dragged.index, area, targetIndex)
|
||||
dropArea = area
|
||||
dropIndex = targetIndex
|
||||
}
|
||||
draggingHomeApp.value = null
|
||||
if (draggedItem && dropOrigin) {
|
||||
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
function stopHomeDrag(): void {
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
draggingHomeApp.value = null
|
||||
}
|
||||
|
||||
@@ -636,6 +829,11 @@ function reorderHomeApp(
|
||||
sourceIndex: number,
|
||||
direction: ReorderDirection,
|
||||
): void {
|
||||
const item = appStore.homeLayout[area][sourceIndex]
|
||||
const appElement = document.querySelector<HTMLElement>(
|
||||
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
||||
)
|
||||
const moveOrigin = appElement?.getBoundingClientRect()
|
||||
const targetIndex = homeKeyboardTarget(
|
||||
appStore.homeLayout,
|
||||
area,
|
||||
@@ -644,6 +842,9 @@ function reorderHomeApp(
|
||||
)
|
||||
if (targetIndex === null) return
|
||||
appStore.moveHomeApp(area, sourceIndex, area, targetIndex)
|
||||
if (item && moveOrigin) {
|
||||
void animateHomeItemDrop(item, area, targetIndex, moveOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
async function addHomePage(): Promise<void> {
|
||||
@@ -676,6 +877,90 @@ function restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
appStore.restoreHomeApp(appId)
|
||||
}
|
||||
|
||||
function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
|
||||
const apps: PhoneAppDefinition[] = []
|
||||
for (const appId of folder.apps) {
|
||||
const app = installedAppsById.value.get(appId)
|
||||
if (app) apps.push(app)
|
||||
}
|
||||
return apps
|
||||
}
|
||||
|
||||
function openFolder(folderId: string): void {
|
||||
pageTransitioning.value = false
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
|
||||
function closeFolder(): void {
|
||||
openedFolderId.value = null
|
||||
renameFolderOnOpenId.value = null
|
||||
}
|
||||
|
||||
function renameOpenedFolder(name: string): void {
|
||||
if (!openedFolderId.value) return
|
||||
appStore.renameHomeFolder(openedFolderId.value, name)
|
||||
}
|
||||
|
||||
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
|
||||
if (!openedFolderId.value) return
|
||||
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function extractOpenedFolderApp(
|
||||
sourceIndex: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
const folder = openedFolder.value
|
||||
if (!folder) return
|
||||
const appId = folder.apps[sourceIndex]
|
||||
const sourceElement = (event.currentTarget as HTMLElement | null)?.closest(
|
||||
'.app-icon-item',
|
||||
)
|
||||
const sourceBounds = sourceElement?.getBoundingClientRect()
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||
).filter((element) => {
|
||||
const area = element.dataset.homeArea as HomeArea
|
||||
const index = Number(element.dataset.homeIndex)
|
||||
return (
|
||||
(area === 'grid' || area === 'dock') &&
|
||||
Number.isInteger(index) &&
|
||||
appStore.homeLayout[area][index] === null
|
||||
)
|
||||
})
|
||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||
if (!closest) return candidate
|
||||
const bounds = candidate.getBoundingClientRect()
|
||||
const closestBounds = closest.getBoundingClientRect()
|
||||
const distance = Math.hypot(
|
||||
event.clientX - (bounds.left + bounds.width / 2),
|
||||
event.clientY - (bounds.top + bounds.height / 2),
|
||||
)
|
||||
const closestDistance = Math.hypot(
|
||||
event.clientX - (closestBounds.left + closestBounds.width / 2),
|
||||
event.clientY - (closestBounds.top + closestBounds.height / 2),
|
||||
)
|
||||
return distance < closestDistance ? candidate : closest
|
||||
}, null)
|
||||
if (!target) return
|
||||
|
||||
const area = target.dataset.homeArea as HomeArea
|
||||
const targetIndex = Number(target.dataset.homeIndex)
|
||||
if (
|
||||
!appStore.extractHomeFolderApp(
|
||||
folder.id,
|
||||
sourceIndex,
|
||||
area,
|
||||
targetIndex,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (sourceBounds) {
|
||||
void animateHomeItemDrop(appId, area, targetIndex, sourceBounds)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch(): void {
|
||||
searchQuery.value = ''
|
||||
searchFocused.value = false
|
||||
@@ -690,6 +975,9 @@ function openAllApps(): void {
|
||||
watch(isEditablePage, (visible) => {
|
||||
if (!visible) editMode.value = false
|
||||
})
|
||||
watch(openedFolder, (folder) => {
|
||||
if (!folder) closeFolder()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -700,6 +988,7 @@ watch(isEditablePage, (visible) => {
|
||||
{
|
||||
'springboard--dragging': dragging,
|
||||
'springboard--editing': editMode,
|
||||
'springboard--folder-open': openedFolder !== null,
|
||||
'springboard--widget-dragging': draggingWidgetId !== null,
|
||||
},
|
||||
]"
|
||||
@@ -763,15 +1052,21 @@ watch(isEditablePage, (visible) => {
|
||||
data-home-area="grid"
|
||||
>
|
||||
<template
|
||||
v-for="(cell, cellIndex) in page.cells"
|
||||
:key="cell?.app.id ?? `grid-empty-${page.page}-${cellIndex}`"
|
||||
v-for="cell in page.cells"
|
||||
:key="cell.renderKey"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="cell"
|
||||
v-if="cell.app && cell.sourceIndex !== null"
|
||||
:app="cell.app"
|
||||
data-home-area="grid"
|
||||
:data-home-app-id="cell.app.id"
|
||||
:data-home-item-key="`app-${cell.app.id}`"
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:edit-mode="editMode"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
||||
}"
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@@ -780,11 +1075,33 @@ watch(isEditablePage, (visible) => {
|
||||
@remove="removeHomeApp(cell.app.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
/>
|
||||
<HomeFolderIcon
|
||||
v-else-if="cell.folder && cell.sourceIndex !== null"
|
||||
:apps="folderPreviewApps(cell.folder)"
|
||||
:default-name="phone.t('Home.folders.defaultName')"
|
||||
data-home-area="grid"
|
||||
:data-home-folder-id="cell.folder.id"
|
||||
:data-home-item-key="cell.folder.id"
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:edit-mode="editMode"
|
||||
:folder="cell.folder"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
||||
}"
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(cell.folder.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="app-grid-slot"
|
||||
data-home-area="grid"
|
||||
:data-home-index="targetHomeIndex(page.page, cellIndex)"
|
||||
:data-home-area="cell.sourceIndex === null ? undefined : 'grid'"
|
||||
:data-home-index="cell.sourceIndex ?? undefined"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</template>
|
||||
@@ -927,7 +1244,7 @@ watch(isEditablePage, (visible) => {
|
||||
|
||||
<Transition name="edit-done">
|
||||
<k-glass
|
||||
v-if="editMode && isEditablePage"
|
||||
v-if="editMode && isEditablePage && !openedFolder"
|
||||
component="button"
|
||||
class="springboard-edit-add"
|
||||
type="button"
|
||||
@@ -940,7 +1257,7 @@ watch(isEditablePage, (visible) => {
|
||||
|
||||
<Transition name="edit-done">
|
||||
<k-glass
|
||||
v-if="editMode && isEditablePage"
|
||||
v-if="editMode && isEditablePage && !openedFolder"
|
||||
component="button"
|
||||
class="springboard-edit-done"
|
||||
type="button"
|
||||
@@ -959,29 +1276,60 @@ watch(isEditablePage, (visible) => {
|
||||
data-home-area="dock"
|
||||
>
|
||||
<template
|
||||
v-for="(app, appIndex) in dockSlots"
|
||||
:key="app?.id ?? `dock-empty-${appIndex}`"
|
||||
v-for="slot in dockSlots"
|
||||
:key="
|
||||
slot.folder?.id ?? slot.app?.id ?? `dock-empty-${slot.index}`
|
||||
"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="app"
|
||||
:app="app"
|
||||
v-if="slot.app"
|
||||
:app="slot.app"
|
||||
data-home-area="dock"
|
||||
:data-home-index="appIndex"
|
||||
:data-home-app-id="slot.app.id"
|
||||
:data-home-item-key="`app-${slot.app.id}`"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
folderHoverTarget === `dock:${slot.index}`,
|
||||
}"
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', appIndex)"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(app.id)"
|
||||
@reorder="reorderHomeApp('dock', appIndex, $event)"
|
||||
@remove="removeHomeApp(slot.app.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
/>
|
||||
<HomeFolderIcon
|
||||
v-else-if="slot.folder"
|
||||
:apps="folderPreviewApps(slot.folder)"
|
||||
:default-name="phone.t('Home.folders.defaultName')"
|
||||
data-home-area="dock"
|
||||
:data-home-folder-id="slot.folder.id"
|
||||
:data-home-item-key="slot.folder.id"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:folder="slot.folder"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
folderHoverTarget === `dock:${slot.index}`,
|
||||
}"
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(slot.folder.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="app-dock-slot"
|
||||
data-home-area="dock"
|
||||
:data-home-index="appIndex"
|
||||
:data-home-index="slot.index"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</template>
|
||||
@@ -1026,6 +1374,22 @@ watch(isEditablePage, (visible) => {
|
||||
</k-button>
|
||||
</nav>
|
||||
|
||||
<Transition name="home-folder">
|
||||
<HomeFolderOverlay
|
||||
v-if="openedFolder"
|
||||
:apps="openedFolderApps"
|
||||
:edit-mode="editMode"
|
||||
:folder="openedFolder"
|
||||
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
|
||||
@close="closeFolder"
|
||||
@edit="enterEditMode"
|
||||
@extract="extractOpenedFolderApp"
|
||||
@move="moveOpenedFolderApp"
|
||||
@rename="renameOpenedFolder"
|
||||
@rename-opened="renameFolderOnOpenId = null"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div class="widget-action-sheet">
|
||||
<k-sheet
|
||||
:opened="widgetActionId !== null"
|
||||
|
||||
@@ -7613,6 +7613,46 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
).length,
|
||||
}
|
||||
}
|
||||
const bootstrapDeviceData =
|
||||
testScenario === 'easyshare-full' &&
|
||||
!deviceData.apps?.payload?.homeLayout
|
||||
? {
|
||||
...deviceData,
|
||||
apps: {
|
||||
...deviceData.apps,
|
||||
payload: {
|
||||
...deviceData.apps.payload,
|
||||
homeLayout: {
|
||||
dock: ['phone', 'messages', 'camera', 'clock'],
|
||||
grid: [
|
||||
{
|
||||
apps: [
|
||||
'mail',
|
||||
'notes',
|
||||
'photos',
|
||||
'app-store',
|
||||
'settings',
|
||||
'map',
|
||||
'citymarkt',
|
||||
'local-pages',
|
||||
'calendar',
|
||||
'darkchat',
|
||||
],
|
||||
id: 'folder-browser-demo-123456',
|
||||
name: 'Los Santos',
|
||||
type: 'folder',
|
||||
},
|
||||
'banking',
|
||||
'weather',
|
||||
'billing',
|
||||
],
|
||||
hidden: [],
|
||||
version: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: deviceData
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -7670,7 +7710,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
}
|
||||
: deviceData.appAuth,
|
||||
}
|
||||
: deviceData,
|
||||
: bootstrapDeviceData,
|
||||
imei: '356938035643809',
|
||||
name: 'Personal iFruit Phone',
|
||||
sim: mockSim,
|
||||
|
||||
@@ -106,6 +106,7 @@ Locales["en"] = {
|
||||
Home = {
|
||||
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
|
||||
dock = "Dock", noApps = "No apps found", removeApp = "Remove {app} from Home Screen", addToHome = "Add {app} to Home Screen", addPage = "Add Home Screen page", deletePage = "Delete current Home Screen page", removedFromHome = "Removed from Home Screen", page = "Page", pages = "Home screen pages",
|
||||
folders = { defaultName = "Folder", close = "Close folder", rename = "Rename Folder", name = "Folder name", pages = "Folder pages" },
|
||||
groups = {
|
||||
suggestions = "Suggestions", recentlyAdded = "Recently Added", games = "Games",
|
||||
productivity = "Productivity", shopping = "Shopping", social = "Social Networks", utilities = "Utilities",
|
||||
|
||||
Reference in New Issue
Block a user