diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f6e415c..bdb4664 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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]) => { diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index daf9f7c..fe209ec 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -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; diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index a5fd6bd..280401f 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -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)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 9377063..d765706 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -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')), ), diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts new file mode 100644 index 0000000..e328370 --- /dev/null +++ b/frontend/src/stores/app-store.test.ts @@ -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 }, + }) + }) +}) diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index fce198d..8ce912c 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -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>, }), 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, }) }, }, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 996d4f1..2845fea 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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' }, diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index fe27ca5..7759abc 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -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 diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index b2d413f..8f8e0d6 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -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 }" >
@@ -192,6 +216,7 @@ function openAllApps(): void { :show-label="false" />
- {{ - phone.t(index < 2 ? `Home.groups.${index}` : 'Home.groups.other') - }} + {{ phone.t(`Home.groups.${group.key}`) }}
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 1ba811c..aef911e 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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" }, diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index ab9817a..bb96cc3 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,8 +4,8 @@ Sky Phone - - + +