mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ENH - organize App Library suggestions
This commit is contained in:
@@ -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]) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user