ENH - organize App Library suggestions

This commit is contained in:
Eichenholz
2026-08-06 20:36:09 +02:00
parent 5caedc7143
commit 9465ed1579
11 changed files with 176 additions and 18 deletions
+10
View File
@@ -28,6 +28,7 @@ import { useMailStore } from '@/stores/mail'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppStoreStore } from '@/stores/app-store'
import { isPhoneAppId } from '@/config/apps'
import { useNotesStore } from '@/stores/notes'
import { useWeatherStore } from '@/stores/weather'
import {
@@ -377,6 +378,15 @@ onMounted(() => {
}
})
watch(
() => route.params.appId,
(appId) => {
if (typeof appId === 'string' && isPhoneAppId(appId)) {
appStore.recordLaunch(appId)
}
},
)
watch(
[() => notifications.requiresAttention, () => calls.activeCall],
([requiresAttention, activeCall]) => {
+11
View File
@@ -854,6 +854,9 @@ button {
}
.springboard-page--library {
padding-top: 62px;
padding-bottom: 25px;
display: flex;
flex-direction: column;
}
.app-library-search,
.app-search {
@@ -882,14 +885,22 @@ button {
color: white;
}
.app-library-groups {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 13px 12px;
margin-top: 16px;
padding-bottom: 24px;
overflow-y: auto;
scrollbar-width: none;
transition:
transform 0.35s ease,
opacity 0.35s ease;
}
.app-library-groups::-webkit-scrollbar {
display: none;
}
.app-library-groups--behind {
transform: scale(0.95);
opacity: 0.45;
+16
View File
@@ -92,6 +92,22 @@ describe('app registry', () => {
expect(isPhoneAppId('camera')).toBe(true)
expect(isPhoneAppId('photos')).toBe(true)
expect(isPhoneAppId('clock')).toBe(true)
expect(
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
).toEqual([
'snake',
'memory',
'number-merge',
'minesweeper',
'tower-stack',
'sky-flappy',
'neon-drop',
])
expect(
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['local-pages', 'phone', 'mail'])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+21
View File
@@ -52,6 +52,7 @@ import type {
export const PHONE_APPS: PhoneAppDefinition[] = [
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CalendarApp.vue')),
),
@@ -65,6 +66,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/calendar',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')),
),
@@ -78,6 +80,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/local-pages',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')),
),
@@ -91,6 +94,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/phone',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MapApp.vue')),
),
@@ -104,6 +108,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/map',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MailApp.vue')),
),
@@ -117,6 +122,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/mail',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
),
@@ -130,6 +136,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/notes',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')),
),
@@ -143,6 +150,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/calculator',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CameraApp.vue')),
),
@@ -156,6 +164,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/camera',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/ClockApp.vue')),
),
@@ -169,6 +178,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/clock',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/WeatherApp.vue')),
),
@@ -182,6 +192,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/weather',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/GalleryApp.vue')),
),
@@ -195,6 +206,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/photos',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
),
@@ -208,6 +220,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/app-store',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
),
@@ -221,6 +234,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/settings',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SnakeApp.vue')),
),
@@ -234,6 +248,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/snake',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MemoryApp.vue')),
),
@@ -247,6 +262,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/memory',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NumberMergeApp.vue')),
),
@@ -260,6 +276,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/number-merge',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MinesweeperApp.vue')),
),
@@ -273,6 +290,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/minesweeper',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/TowerStackApp.vue')),
),
@@ -286,6 +304,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/tower-stack',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SkyFlappyApp.vue')),
),
@@ -299,6 +318,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/sky-flappy',
},
{
category: 'shopping',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CityMarktApp.vue')),
),
@@ -312,6 +332,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/citymarkt',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NeonDropApp.vue')),
),
+34
View File
@@ -0,0 +1,34 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStoreStore } from '@/stores/app-store'
const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() }))
vi.mock('@/stores/phone', () => ({
usePhoneStore: () => ({
saveDeviceNamespace: mocks.saveDeviceNamespace,
}),
}))
describe('app store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.saveDeviceNamespace.mockReset()
})
it('hydrates valid launch counts and persists launches with claimed apps', () => {
const apps = useAppStoreStore()
apps.hydrate({
claimedApps: ['snake'],
launchCounts: { mail: 3, invalid: 20, phone: -1 },
})
apps.recordLaunch('mail')
expect(apps.launchCounts).toEqual({ mail: 4 })
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: ['snake'],
launchCounts: { mail: 4 },
})
})
})
+25 -1
View File
@@ -1,10 +1,13 @@
import { defineStore } from 'pinia'
import { isPhoneAppId } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppId } from '@/types/apps'
export const useAppStoreStore = defineStore('app-store', {
state: () => ({
claimedApps: [] as string[],
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
}),
actions: {
claimApp(id: string): void {
@@ -14,14 +17,35 @@ export const useAppStoreStore = defineStore('app-store', {
}
},
hydrate(payload: unknown): void {
const data = payload as { claimedApps?: unknown } | null
const data = payload as {
claimedApps?: unknown
launchCounts?: unknown
} | null
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter((id): id is string => typeof id === 'string')
: []
this.launchCounts = {}
if (data?.launchCounts && typeof data.launchCounts === 'object') {
for (const [appId, count] of Object.entries(data.launchCounts)) {
if (
isPhoneAppId(appId) &&
typeof count === 'number' &&
Number.isFinite(count) &&
count > 0
) {
this.launchCounts[appId] = Math.floor(count)
}
}
}
},
recordLaunch(appId: LaunchablePhoneAppId): void {
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
this.persist()
},
persist(): void {
usePhoneStore().saveDeviceNamespace('apps', {
claimedApps: this.claimedApps,
launchCounts: this.launchCounts,
})
},
},
+9 -1
View File
@@ -935,7 +935,15 @@ const defaultLocales: LocaleTree = {
noApps: 'No apps found',
page: 'Page',
pages: 'Home screen pages',
groups: { 0: 'Suggestions', 1: 'Recently Added', other: 'Other' },
groups: {
suggestions: 'Suggestions',
recentlyAdded: 'Recently Added',
games: 'Games',
productivity: 'Productivity',
shopping: 'Shopping',
social: 'Social Networks',
utilities: 'Utilities',
},
widgets: {
label: 'Widgets',
weather: { city: 'Los Santos', condition: 'Partly Cloudy' },
+8
View File
@@ -25,6 +25,13 @@ export type PhoneAppId =
export type LaunchablePhoneAppId = PhoneAppId
export type PhoneAppCategory =
| 'games'
| 'productivity'
| 'shopping'
| 'social'
| 'utilities'
export type AppLaunchOrigin = {
borderRadius: number
scaleX: number
@@ -34,6 +41,7 @@ export type AppLaunchOrigin = {
}
export type PhoneAppDefinition = {
category: PhoneAppCategory
component: Component | null
dockOrder: number | null
gridOrder: number
+36 -13
View File
@@ -5,12 +5,21 @@ import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import SpringboardWidgets from '@/components/SpringboardWidgets.vue'
import { PHONE_APPS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
import { paginateItems } from '@/utils/pages'
const APPS_PER_HOME_PAGE = 16
const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
'games',
'productivity',
'social',
'utilities',
'shopping',
]
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const searchQuery = ref('')
const searchFocused = ref(false)
const showAllApps = ref(false)
@@ -43,13 +52,28 @@ const filteredApps = computed(() => {
)
})
const appGroups = computed(() => {
const groups: PhoneAppDefinition[][] = []
for (let index = 0; index < gridApps.value.length; index += 3) {
groups.push(gridApps.value.slice(index, index + 3))
}
return groups.map((apps, index) => ({
apps,
moreApps: groups[(index + 1) % groups.length] ?? [],
const suggestions = [...gridApps.value]
.sort(
(a, b) =>
(appStore.launchCounts[b.id] ?? 0) -
(appStore.launchCounts[a.id] ?? 0) || a.gridOrder - b.gridOrder,
)
.slice(0, 7)
const recentlyAdded = [...gridApps.value]
.sort((a, b) => b.gridOrder - a.gridOrder)
.slice(0, 7)
const groups = [
{ apps: suggestions, key: 'suggestions' },
{ apps: recentlyAdded, key: 'recentlyAdded' },
...APP_LIBRARY_CATEGORIES.map((category) => ({
apps: gridApps.value.filter((app) => app.category === category),
key: category,
})),
]
return groups.map((group) => ({
...group,
apps: group.apps.slice(0, 3),
moreApps: group.apps.slice(3),
}))
})
const alphabeticalGroups = computed(() => {
@@ -179,8 +203,8 @@ function openAllApps(): void {
:class="{ 'app-library-groups--behind': showAllApps }"
>
<article
v-for="(group, index) in appGroups"
:key="index"
v-for="group in appGroups"
:key="group.key"
class="app-library-group"
>
<div class="app-library-group__icons">
@@ -192,6 +216,7 @@ function openAllApps(): void {
:show-label="false"
/>
<button
v-if="group.moreApps.length"
class="app-library-more"
type="button"
:aria-label="phone.t('Home.allApps')"
@@ -206,9 +231,7 @@ function openAllApps(): void {
/>
</button>
</div>
<span>{{
phone.t(index < 2 ? `Home.groups.${index}` : 'Home.groups.other')
}}</span>
<span>{{ phone.t(`Home.groups.${group.key}`) }}</span>
</article>
</div>
+4 -1
View File
@@ -26,7 +26,10 @@ Locales["en"] = {
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", page = "Page", pages = "Home screen pages",
groups = { ["0"] = "Suggestions", ["1"] = "Recently Added", other = "Other" },
groups = {
suggestions = "Suggestions", recentlyAdded = "Recently Added", games = "Games",
productivity = "Productivity", shopping = "Shopping", social = "Social Networks", utilities = "Utilities",
},
widgets = {
label = "Widgets",
weather = { city = "Los Santos", condition = "Partly Cloudy" },
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-BH8XBcJS.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-84HL6wFu.css">
<script type="module" crossorigin src="./assets/sky-index-C4BpdHqp.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-pZFmE4TE.css">
</head>
<body>
<div id="app"></div>