ADD - support home screen app folders

This commit is contained in:
smx.pusha
2026-08-13 13:15:25 +02:00
parent 90909e41f2
commit bc4b439eb6
15 changed files with 1976 additions and 194 deletions
+55 -4
View File
@@ -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 {
+239
View File
@@ -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>
+38 -1
View File
@@ -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()
+90 -2
View File
@@ -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
+7
View File
@@ -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',
+33
View File
@@ -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;
}
}
+28 -22
View File
@@ -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>
+154 -14
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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', () => {
+1 -1
View File
@@ -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
+412 -48
View File
@@ -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"
+41 -1
View File
@@ -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,
+1
View File
@@ -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",