mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 11:01:34 +00:00
ENH - refine App Store experience
This commit is contained in:
@@ -715,8 +715,9 @@ export function getPhoneAppLabel(
|
||||
|
||||
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
||||
return app.kind === 'external'
|
||||
? app.removable
|
||||
: !NON_REMOVABLE_PHONE_APP_IDS.has(app.id)
|
||||
? app.removable && !app.defaultInstalled
|
||||
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
!NON_REMOVABLE_PHONE_APP_IDS.has(app.id)
|
||||
}
|
||||
|
||||
export function isLaunchablePhoneApp(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_INSTALLED_PHONE_APP_IDS,
|
||||
NON_REMOVABLE_PHONE_APP_IDS,
|
||||
} from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import {
|
||||
@@ -184,26 +183,26 @@ describe('app store', () => {
|
||||
it('fully uninstalls removable apps and allows downloading them again', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
apps.hydrate({ claimedApps: ['snake'] })
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect(apps.uninstallApp('calculator')).toBe(true)
|
||||
expect(apps.isInstalled('calculator')).toBe(false)
|
||||
expect(apps.uninstalledApps).toEqual(['calculator'])
|
||||
expect(apps.homeLayout.hidden).toContain('calculator')
|
||||
expect(apps.uninstallApp('snake')).toBe(true)
|
||||
expect(apps.isInstalled('snake')).toBe(false)
|
||||
expect(apps.uninstalledApps).toEqual(['snake'])
|
||||
expect(apps.homeLayout.hidden).toContain('snake')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
uninstalledApps: ['calculator'],
|
||||
uninstalledApps: ['snake'],
|
||||
})
|
||||
|
||||
apps.installApp('calculator')
|
||||
apps.installApp('snake')
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(apps.isInstalled('calculator')).toBe(true)
|
||||
expect(apps.isInstalled('snake')).toBe(true)
|
||||
expect(apps.uninstalledApps).toEqual([])
|
||||
expect(apps.homeLayout.hidden).not.toContain('calculator')
|
||||
expect(apps.homeLayout.hidden).not.toContain('snake')
|
||||
})
|
||||
|
||||
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
|
||||
@@ -211,49 +210,25 @@ describe('app store', () => {
|
||||
|
||||
apps.hydrate({
|
||||
uninstalledApps: ['calculator', 'phone', 'not-an-app'],
|
||||
updatedAppReleases: {
|
||||
calculator: '2026-08-14',
|
||||
phone: '2026-08-14',
|
||||
snake: 'x'.repeat(64),
|
||||
},
|
||||
})
|
||||
|
||||
expect(apps.uninstalledApps).toEqual(['calculator'])
|
||||
expect(apps.isInstalled('calculator')).toBe(false)
|
||||
expect(apps.uninstalledApps).toEqual([])
|
||||
expect(apps.isInstalled('calculator')).toBe(true)
|
||||
expect(apps.isInstalled('phone')).toBe(true)
|
||||
expect(apps.updatedAppReleases).toEqual({ phone: '2026-08-14' })
|
||||
})
|
||||
|
||||
it('protects system apps from full uninstallation', () => {
|
||||
it('protects every default app from full uninstallation', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect(apps.uninstallApp('phone')).toBe(false)
|
||||
expect(apps.isInstalled('phone')).toBe(true)
|
||||
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
|
||||
expect(apps.uninstallApp(appId)).toBe(false)
|
||||
expect(apps.isInstalled(appId)).toBe(true)
|
||||
}
|
||||
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates installed apps and persists the current release', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
apps.updateApp('phone', '2026-08-14')
|
||||
expect(apps.updatingApps.phone).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(1800)
|
||||
|
||||
expect(apps.updatingApps.phone).toBeUndefined()
|
||||
expect(apps.updatedAppReleases.phone).toBe('2026-08-14')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
updatedAppReleases: { phone: '2026-08-14' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores duplicate installation requests and invalid persisted ids', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
@@ -267,7 +242,7 @@ describe('app store', () => {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reinstalls core and claimed apps removed from the Home Screen', () => {
|
||||
it('reinstalls claimed apps removed from the Home Screen', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -279,8 +254,8 @@ describe('app store', () => {
|
||||
apps.installApp('notes')
|
||||
apps.installApp('memory')
|
||||
|
||||
expect(apps.installingApps).toEqual({ notes: true, memory: true })
|
||||
expect(apps.homeLayout.hidden).toEqual(['notes', 'memory'])
|
||||
expect(apps.installingApps).toEqual({ memory: true })
|
||||
expect(apps.homeLayout.hidden).toEqual(['memory'])
|
||||
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
@@ -289,25 +264,15 @@ describe('app store', () => {
|
||||
expect(apps.homeLayout.grid).toContain('notes')
|
||||
expect(apps.homeLayout.grid).toContain('memory')
|
||||
expect(apps.claimedApps).toEqual(['memory'])
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('prevents protected apps from being removed from the Home Screen', () => {
|
||||
it('prevents every default app from being removed from the Home Screen', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect([...NON_REMOVABLE_PHONE_APP_IDS]).toEqual([
|
||||
'app-store',
|
||||
'settings',
|
||||
'camera',
|
||||
'photos',
|
||||
'phone',
|
||||
'messages',
|
||||
'mail',
|
||||
'health',
|
||||
])
|
||||
for (const appId of NON_REMOVABLE_PHONE_APP_IDS) {
|
||||
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
|
||||
apps.removeHomeApp(appId)
|
||||
expect(apps.homeLayout.hidden).not.toContain(appId)
|
||||
}
|
||||
@@ -332,17 +297,18 @@ describe('app store', () => {
|
||||
|
||||
it('persists home reordering and removal independently from installation', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
apps.hydrate({ claimedApps: ['memory'] })
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
apps.moveHomeApp('grid', notesIndex, 'grid', 0)
|
||||
expect(apps.homeLayout.grid[0]).toBe('notes')
|
||||
const memoryIndex = apps.homeLayout.grid.indexOf('memory')
|
||||
apps.moveHomeApp('grid', memoryIndex, 'grid', 0)
|
||||
expect(apps.homeLayout.grid[0]).toBe('memory')
|
||||
|
||||
apps.removeHomeApp('notes')
|
||||
expect(apps.homeLayout.grid).not.toContain('notes')
|
||||
expect(apps.homeLayout.hidden).toContain('notes')
|
||||
apps.removeHomeApp('memory')
|
||||
expect(apps.homeLayout.grid).not.toContain('memory')
|
||||
expect(apps.homeLayout.hidden).toContain('memory')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
claimedApps: ['memory'],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
})
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const INSTALL_DURATION_MS = 3000
|
||||
const UPDATE_DURATION_MS = 1800
|
||||
|
||||
type PendingInstallation = {
|
||||
deviceImei: string
|
||||
@@ -39,19 +38,10 @@ type PendingInstallation = {
|
||||
token: symbol
|
||||
}
|
||||
|
||||
type PendingUpdate = {
|
||||
deviceImei: string
|
||||
timer: ReturnType<typeof globalThis.setTimeout>
|
||||
}
|
||||
|
||||
const pendingInstallations = new WeakMap<
|
||||
object,
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
const pendingUpdates = new WeakMap<
|
||||
object,
|
||||
Map<LaunchablePhoneAppId, PendingUpdate>
|
||||
>()
|
||||
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return [...PHONE_APPS]
|
||||
@@ -112,7 +102,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
updatedAppReleases: {} as Partial<Record<LaunchablePhoneAppId, string>>,
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
getDefaultGridIds(),
|
||||
@@ -120,7 +109,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
),
|
||||
hydrated: false,
|
||||
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
updatingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
|
||||
}),
|
||||
actions: {
|
||||
@@ -157,14 +145,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
pendingInstallations.delete(this)
|
||||
}
|
||||
this.installingApps = {}
|
||||
const updates = pendingUpdates.get(this)
|
||||
if (updates) {
|
||||
for (const update of updates.values()) {
|
||||
globalThis.clearTimeout(update.timer)
|
||||
}
|
||||
pendingUpdates.delete(this)
|
||||
}
|
||||
this.updatingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
const installed = this.isInstalled(id)
|
||||
@@ -244,7 +224,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
homeLayout?: unknown
|
||||
launchCounts?: unknown
|
||||
uninstalledApps?: unknown
|
||||
updatedAppReleases?: unknown
|
||||
} | null
|
||||
const layoutVersion =
|
||||
data?.homeLayout && typeof data.homeLayout === 'object'
|
||||
@@ -268,29 +247,9 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return !!app && isPhoneAppRemovable(app)
|
||||
})
|
||||
: []
|
||||
this.updatedAppReleases = {}
|
||||
if (
|
||||
data?.updatedAppReleases &&
|
||||
typeof data.updatedAppReleases === 'object'
|
||||
) {
|
||||
for (const [id, release] of Object.entries(data.updatedAppReleases)) {
|
||||
if (
|
||||
isPhoneAppId(id) &&
|
||||
typeof release === 'string' &&
|
||||
release.length <= 32
|
||||
) {
|
||||
this.updatedAppReleases[id] = release
|
||||
}
|
||||
}
|
||||
}
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
for (const appId of Object.keys(this.updatedAppReleases)) {
|
||||
if (!isPhoneAppId(appId) || !installedIds.includes(appId)) {
|
||||
delete this.updatedAppReleases[appId as LaunchablePhoneAppId]
|
||||
}
|
||||
}
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
installedIds,
|
||||
@@ -512,52 +471,11 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
if (!this.uninstalledApps.includes(appId)) {
|
||||
this.uninstalledApps.push(appId)
|
||||
}
|
||||
delete this.updatedAppReleases[appId]
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
|
||||
return true
|
||||
},
|
||||
updateApp(appId: LaunchablePhoneAppId, release: string): void {
|
||||
if (
|
||||
!this.isInstalled(appId) ||
|
||||
this.updatingApps[appId] ||
|
||||
!release.trim()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const deviceImei = phone.device?.imei
|
||||
if (!phone.isOpen || !deviceImei) {
|
||||
console.error(
|
||||
`[App store] Update cancelled because no phone device is open for ${appId}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const updates =
|
||||
pendingUpdates.get(this) ??
|
||||
new Map<LaunchablePhoneAppId, PendingUpdate>()
|
||||
this.updatingApps[appId] = true
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
updates.delete(appId)
|
||||
if (!updates.size) pendingUpdates.delete(this)
|
||||
delete this.updatingApps[appId]
|
||||
const activePhone = usePhoneStore()
|
||||
if (!activePhone.isOpen || activePhone.device?.imei !== deviceImei) {
|
||||
console.error(
|
||||
`[App store] Update cancelled because the active phone changed for ${appId}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isInstalled(appId)) return
|
||||
this.updatedAppReleases[appId] = release
|
||||
this.persist()
|
||||
}, UPDATE_DURATION_MS)
|
||||
updates.set(appId, { deviceImei, timer })
|
||||
pendingUpdates.set(this, updates)
|
||||
},
|
||||
persist(): void {
|
||||
usePhoneStore().saveDeviceNamespace('apps', {
|
||||
claimedApps: this.claimedApps,
|
||||
@@ -566,9 +484,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
...(this.uninstalledApps.length
|
||||
? { uninstalledApps: this.uninstalledApps }
|
||||
: {}),
|
||||
...(Object.keys(this.updatedAppReleases).length
|
||||
? { updatedAppReleases: this.updatedAppReleases }
|
||||
: {}),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1861,8 +1861,6 @@ const defaultLocales: LocaleTree = {
|
||||
search: 'Search',
|
||||
},
|
||||
today: {
|
||||
freshDaily: 'A fresh selection every day',
|
||||
dailyEdition: 'Daily Edition',
|
||||
featured: 'Featured today',
|
||||
gameHighlight: 'Game highlight',
|
||||
appHighlight: 'App highlight',
|
||||
@@ -1896,11 +1894,52 @@ const defaultLocales: LocaleTree = {
|
||||
essentialApps: 'Essential Apps',
|
||||
essentialGames: 'Essential Games',
|
||||
},
|
||||
taglines: {
|
||||
health: 'Health, activity and medical information',
|
||||
'weazel-news': 'Local news and city stories',
|
||||
companies: 'Businesses, jobs and services',
|
||||
music: 'Songs, playlists and audio',
|
||||
picstagram: 'Photo sharing and social feed',
|
||||
feather: 'Short posts and city conversations',
|
||||
fliptok: 'Short videos and trends',
|
||||
flare: 'Social posts and live moments',
|
||||
calendar: 'Events, schedules and reminders',
|
||||
radio: 'Live radio and team communication',
|
||||
'local-pages': 'Local businesses and community pages',
|
||||
crewlink: 'Crews, members and coordination',
|
||||
phone: 'Calls, contacts and voicemail',
|
||||
messages: 'Private texts and media',
|
||||
darkchat: 'Private messaging and secure groups',
|
||||
garage: 'Vehicles, parking and valet',
|
||||
house: 'Homes, access and management',
|
||||
map: 'Navigation and nearby places',
|
||||
skyride: 'City rides and driver booking',
|
||||
banking: 'Accounts, transfers and payments',
|
||||
billing: 'Invoices and payment requests',
|
||||
mail: 'Email and attachments',
|
||||
notes: 'Notes, lists and ideas',
|
||||
memos: 'Voice recordings and memos',
|
||||
calculator: 'Everyday and scientific calculations',
|
||||
camera: 'Photos and video capture',
|
||||
clock: 'Time, alarms and timers',
|
||||
weather: 'Forecasts and current conditions',
|
||||
photos: 'Photo and video library',
|
||||
settings: 'Phone preferences and security',
|
||||
snake: 'Classic arcade snake',
|
||||
memory: 'Card matching challenge',
|
||||
'number-merge': 'Number puzzle and strategy',
|
||||
minesweeper: 'Classic logic puzzle',
|
||||
'tower-stack': 'Precision tower-building game',
|
||||
'sky-flappy': 'Fast arcade flying challenge',
|
||||
citymarkt: 'Local marketplace and listings',
|
||||
'neon-drop': 'Neon block-dropping puzzle',
|
||||
},
|
||||
search: {
|
||||
recommended: 'Recommended',
|
||||
discover: 'Discover',
|
||||
results: 'Search Results',
|
||||
noResults: 'No matching apps or games found.',
|
||||
noResults: 'No Results',
|
||||
noResultsBody: 'Try searching for a different app or game.',
|
||||
ad: 'Ad',
|
||||
topApps: 'Top Apps',
|
||||
topGames: 'Top Games',
|
||||
@@ -1917,7 +1956,7 @@ const defaultLocales: LocaleTree = {
|
||||
games: 'Games',
|
||||
library: 'Your Library',
|
||||
myApps: 'My Apps',
|
||||
update: 'UPDATE',
|
||||
downloadedOn: 'Downloaded {date}',
|
||||
uninstall: 'Uninstall',
|
||||
uninstallApp: 'Uninstall {app}',
|
||||
uninstallTitle: 'Uninstall this app?',
|
||||
|
||||
@@ -9,10 +9,13 @@ const source = readFileSync(
|
||||
)
|
||||
|
||||
describe('AppStoreAction contract', () => {
|
||||
it('uses an Apple-style cloud download icon instead of GET text', () => {
|
||||
expect(source).toContain("import { CloudDownload } from 'lucide-vue-next'")
|
||||
it('uses localized text for the download button instead of an icon', () => {
|
||||
expect(source).toContain('v-if="action === \'get\'"')
|
||||
expect(source).not.toContain("phone.t('Apps.appStore.get')")
|
||||
expect(source).toContain("phone.t('Apps.appStore.get')")
|
||||
expect(source).not.toContain('CloudDownload')
|
||||
expect(source).toContain(
|
||||
":class=\"{ 'app-store-action--icon': action === 'installing' }\"",
|
||||
)
|
||||
})
|
||||
|
||||
it('shows a timed circular installation progress with a center stop mark', () => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { CloudDownload } from 'lucide-vue-next'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
defineProps<{
|
||||
@@ -13,15 +11,12 @@ const phone = usePhoneStore()
|
||||
<template>
|
||||
<span
|
||||
class="app-store-action"
|
||||
:class="{ 'app-store-action--icon': action !== 'open' }"
|
||||
:class="{ 'app-store-action--icon': action === 'installing' }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CloudDownload
|
||||
v-if="action === 'get'"
|
||||
class="app-store-action__download"
|
||||
:size="24"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<template v-if="action === 'get'">
|
||||
{{ phone.t('Apps.appStore.get') }}
|
||||
</template>
|
||||
<span
|
||||
v-else-if="action === 'installing'"
|
||||
class="app-store-action__progress"
|
||||
@@ -49,10 +44,6 @@ const phone = usePhoneStore()
|
||||
color: var(--sky-app-accent);
|
||||
}
|
||||
|
||||
.app-store-action__download {
|
||||
filter: drop-shadow(0 2px 5px var(--sky-app-accent-soft));
|
||||
}
|
||||
|
||||
.app-store-action__progress {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
@@ -19,7 +19,6 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('<SkyNavbar')
|
||||
expect(source).toContain('<SkyPillNavigation')
|
||||
expect(source).toContain('<SkySearchbar')
|
||||
expect(source).toContain('<SkySpinner')
|
||||
expect(source.match(/<SkyScrollArea/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -46,7 +45,19 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('const searchRecommendations = computed')
|
||||
expect(source).toContain('const searchDiscoverCards = computed')
|
||||
expect(source).toContain('v-if="!hasSearchQuery"')
|
||||
expect(source).toContain(
|
||||
":class=\"{ 'app-store-navbar--search': tab === 'search' }\"",
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.app-store-navbar--search\s*\{[^}]*margin-bottom:\s*-24px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.app-store-navbar--search :deep\(\.sky-navbar__subnavbar\)\s*\{[^}]*- 24px/s,
|
||||
)
|
||||
expect(source).toContain('class="store-search__recommendations"')
|
||||
expect(source).toMatch(
|
||||
/\.store-search__recommendations article\.store-search__recommendation--promoted\s*\{[^}]*margin:\s*0 calc\(0px - var\(--sky-space-2\)\);[^}]*border-radius:\s*var\(--sky-radius-control\);[^}]*padding:\s*var\(--sky-space-3\) var\(--sky-space-2\);[^}]*rgba\(10, 132, 255, 0\.18\)/s,
|
||||
)
|
||||
expect(source).toContain('class="store-search__discover-grid"')
|
||||
expect(source).toContain(
|
||||
'class="store-list store-list--browse store-list--search-results"',
|
||||
@@ -58,16 +69,34 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toMatch(
|
||||
/\.store-search__discover-grid > button\s*\{[^}]*min-height:\s*124px/s,
|
||||
)
|
||||
expect(source).toContain('<SkyEmptyState')
|
||||
expect(source).toContain("phone.t('Apps.appStore.search.noResultsBody')")
|
||||
expect(source).toContain('<Search :size="38"')
|
||||
expect(source).toMatch(
|
||||
/\.store-search__empty\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens profile app management with update and uninstall actions', () => {
|
||||
it('opens profile app management with open and uninstall actions', () => {
|
||||
expect(source).toContain('@click="profileOpened = true"')
|
||||
expect(source).toContain('const installedApps = computed')
|
||||
expect(source).toContain('return !appStore.isInstalled(app.id)')
|
||||
expect(source).toContain(
|
||||
'class="store-account__apps phone-effect--expensive-shadow"',
|
||||
)
|
||||
expect(source).toContain('appStore.updateApp(app.id, currentRelease)')
|
||||
expect(source).toContain('@click="handleManagedApp(app)"')
|
||||
expect(source).toContain("phone.t('Apps.appStore.open')")
|
||||
expect(source).toContain("phone.t('Apps.appStore.account.downloadedOn'")
|
||||
expect(source).toContain('<small>{{ downloadDateDescription }}</small>')
|
||||
expect(source).toMatch(
|
||||
/\.store-account__primary-action\s*\{[^}]*min-width:\s*60px;[^}]*height:\s*30px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-account__summary > div\s*\{[^}]*border:\s*1px solid var\(--sky-hairline\);[^}]*border-radius:\s*var\(--sky-radius-card\);[^}]*background:\s*var\(--sky-surface-muted\);/s,
|
||||
)
|
||||
expect(source).not.toContain('appStore.updateApp(')
|
||||
expect(source).not.toContain('appStore.updatedAppReleases')
|
||||
expect(source).not.toContain('appStore.updatingApps')
|
||||
expect(source).toContain(
|
||||
'appStore.uninstallApp(uninstallCandidate.value.id)',
|
||||
)
|
||||
@@ -136,10 +165,16 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('gives interactive download, update and remove buttons subtle pointer feedback', () => {
|
||||
it('gives App Store actions calm pointer feedback without moving app rows', () => {
|
||||
expect(source).toContain('@media (hover: hover) and (pointer: fine)')
|
||||
expect(source).toMatch(
|
||||
/button:not\(\.store-ranking__detail-link\):not\(:disabled\):hover\)\s*\{[^}]*brightness\(1\.08\)[^}]*translateY\(-1px\)/s,
|
||||
/button:not\(\.store-ranking__detail-link\):not\([\s\S]*?\.store-list__detail-link[\s\S]*?:hover[\s\S]*?brightness\(1\.08\)[\s\S]*?translateY\(-1px\)/,
|
||||
)
|
||||
expect(source).toContain(
|
||||
'.store-list--browse article:hover',
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/button\.store-action-button--get:not\(\.store-list__detail-link\):hover\s*\{[^}]*background:\s*var\(--sky-app-accent-soft\);[^}]*filter:\s*none;[^}]*transform:\s*none;/s,
|
||||
)
|
||||
expect(source).toContain('.app-store-page .store-action-button--icon:hover')
|
||||
expect(source).toContain(
|
||||
@@ -162,7 +197,10 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain(
|
||||
'class="store-highlight store-highlight--compact phone-effect--expensive-shadow"',
|
||||
)
|
||||
expect(source).toContain('class="store-today__edition"')
|
||||
expect(source).not.toContain('store-today__edition')
|
||||
expect(source).not.toContain('todayDate')
|
||||
expect(source).not.toContain("phone.t('Apps.appStore.today.freshDaily')")
|
||||
expect(source).not.toContain('store-highlight__story-number')
|
||||
expect(source).toContain(
|
||||
'class="store-ranking phone-effect--expensive-shadow"',
|
||||
)
|
||||
@@ -184,38 +222,95 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('@click.stop="handleApp(dailyHighlights[0])"')
|
||||
expect(source).toContain('@click.stop="handleApp(finalHighlight)"')
|
||||
expect(source).toContain('.store-highlight:hover')
|
||||
expect(source).toMatch(
|
||||
/\.store-highlight__orbit\s*\{[^}]*border:\s*0;[^}]*radial-gradient[^}]*box-shadow:\s*none;[^}]*filter:\s*blur\(14px\) saturate\(125%\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-highlight__orbit::before\s*\{[^}]*radial-gradient[^}]*filter:\s*blur\(12px\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-browse-feature__art > span\s*\{[^}]*radial-gradient[^}]*box-shadow:\s*none;[^}]*filter:\s*blur\(11px\) saturate\(125%\);/s,
|
||||
)
|
||||
expect(source).not.toContain('0 0 0 28px')
|
||||
expect(source).not.toContain('0 0 0 22px')
|
||||
expect(source).toMatch(
|
||||
/\.store-highlight__footer\s*\{[^}]*border-radius:\s*0 0 var\(--sky-radius-card\) var\(--sky-radius-card\);[^}]*linear-gradient/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-highlight__footer::before\s*\{[^}]*top:\s*-28px;[^}]*linear-gradient/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens Top Today apps while keeping their direct app actions separate', () => {
|
||||
expect(source).toContain('class="store-ranking__detail-link"')
|
||||
expect(source).toContain('v-for="app in topToday"')
|
||||
expect(source).not.toContain('store-ranking__position')
|
||||
expect(source).toContain('@click="openAppDetail(app)"')
|
||||
expect(source).toContain('@click.stop="handleApp(app)"')
|
||||
expect(source).toContain(
|
||||
'.store-ranking li > button:not(.store-ranking__detail-link)',
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-ranking li > button:not\(\.store-ranking__detail-link\),[\s\S]*?min-width:\s*68px;/,
|
||||
)
|
||||
expect(source).not.toContain('.store-ranking__detail-link:hover')
|
||||
expect(source).toContain(
|
||||
'button:not(.store-ranking__detail-link):not(:disabled):hover',
|
||||
'button:not(.store-ranking__detail-link):not(',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps App Store actions compatible with the FiveM CEF target', () => {
|
||||
expect(source.match(/class="store-action-button"/g)).toHaveLength(8)
|
||||
expect(source).not.toContain("appAction(app) !== 'open'")
|
||||
expect(source).toContain("appAction(app) === 'installing'")
|
||||
expect(source).toContain("'store-action-button--get': appAction(app) === 'get'")
|
||||
expect(source).toContain(
|
||||
'> button.store-action-button--get:not(.store-browse-feature__details),',
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-ranking li > button\.store-action-button--get:not\(\.store-ranking__detail-link\),[\s\S]*?\.store-list article > button\.store-action-button--get:not\(\.store-list__detail-link\)\s*\{[^}]*width:\s*56px;[^}]*min-width:\s*56px;[^}]*min-height:\s*32px;[^}]*height:\s*32px;[^}]*font-size:\s*11px;[^}]*font-weight:\s*850;/s,
|
||||
)
|
||||
expect(source).not.toContain(':has(')
|
||||
expect(source).not.toContain('color-mix(')
|
||||
})
|
||||
|
||||
it('builds clean Apps and Games pages with rotating features', () => {
|
||||
it('builds shorter horizontally snapping Apps and Games features', () => {
|
||||
expect(source).not.toContain('class="store-browse__filters"')
|
||||
expect(source).not.toContain('browseFilter')
|
||||
expect(source).toContain(
|
||||
'class="store-browse-feature phone-effect--expensive-shadow"',
|
||||
)
|
||||
expect(source).toContain('class="store-list store-list--browse"')
|
||||
expect(source).not.toContain('class="store-list__tags"')
|
||||
expect(source).toContain('class="store-list__tagline"')
|
||||
expect(source).toContain('<small>{{ appStoreTagline(app) }}</small>')
|
||||
expect(source).toContain('function appStoreTagline(')
|
||||
expect(source).toContain('app.description.trim() || app.developer')
|
||||
expect(source).toMatch(
|
||||
/\.store-list__tagline\s*\{[^}]*color:\s*var\(--sky-muted\);[^}]*font-size:\s*11px;[^}]*font-weight:\s*500;/s,
|
||||
)
|
||||
expect(source).toContain('featuredCandidates')
|
||||
expect(source).toContain('globalThis.setInterval')
|
||||
expect(source).toContain('}, 6500)')
|
||||
expect(source).toContain('globalThis.clearInterval')
|
||||
expect(source).toContain('ref="featuredScroller"')
|
||||
expect(source).toContain('@scroll.passive="updateFeaturedSlide"')
|
||||
expect(source).toContain('@wheel="handleFeaturedWheel"')
|
||||
expect(source).toContain('function handleFeaturedWheel(event: WheelEvent)')
|
||||
expect(source).toContain('event.preventDefault()')
|
||||
expect(source).toContain('v-for="(app, index) in featuredCandidates"')
|
||||
expect(source).toContain(":class=\"{ 'is-active': featuredSlide === index }\"")
|
||||
expect(source).toContain('@click="scrollToFeatured(index)"')
|
||||
expect(source).not.toContain('globalThis.setInterval')
|
||||
expect(source).toMatch(
|
||||
/\.store-browse__featured-scroll\s*\{[^}]*overflow-x:\s*auto;[^}]*scroll-behavior:\s*smooth;[^}]*scroll-snap-type:\s*x mandatory;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-browse-feature\s*\{[^}]*min-height:\s*246px;[^}]*opacity:\s*0\.72;[^}]*scroll-snap-align:\s*start;[^}]*scroll-snap-stop:\s*always;[^}]*transform:\s*scale\(0\.97\);[^}]*transition:/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-browse-feature\.is-active\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*scale\(1\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-browse-feature footer::before\s*\{[^}]*top:\s*-28px;[^}]*linear-gradient\(180deg, transparent, rgba\(5, 8, 16, 0\.3\)\);[^}]*backdrop-filter:\s*blur\(7px\) saturate\(110%\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-browse__pages button\s*\{[^}]*width:\s*var\(--sky-touch-target\)[^}]*height:\s*var\(--sky-touch-target\)/s,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,11 @@ describe('AppStoreDetail contract', () => {
|
||||
expect(source).toContain("emit('share')")
|
||||
expect(source).toContain('<AppStoreAction :action="action" />')
|
||||
expect(source).toContain(
|
||||
":class=\"{ 'store-detail__action--icon': action !== 'open' }\"",
|
||||
"'store-detail__action--icon': action === 'installing'",
|
||||
)
|
||||
expect(source).toContain("'store-detail__action--get': action === 'get'")
|
||||
expect(source).toMatch(
|
||||
/\.store-detail__hero \.store-detail__action--get\s*\{[^}]*min-width:\s*54px;[^}]*min-height:\s*30px;[^}]*height:\s*30px;/s,
|
||||
)
|
||||
expect(source).not.toContain(':has(')
|
||||
expect(source).toMatch(
|
||||
|
||||
@@ -132,7 +132,10 @@ function updateActivePreview(): void {
|
||||
<button
|
||||
type="button"
|
||||
class="store-detail__action"
|
||||
:class="{ 'store-detail__action--icon': action !== 'open' }"
|
||||
:class="{
|
||||
'store-detail__action--icon': action === 'installing',
|
||||
'store-detail__action--get': action === 'get',
|
||||
}"
|
||||
:disabled="action === 'installing'"
|
||||
@click="emit('action')"
|
||||
>
|
||||
@@ -389,6 +392,14 @@ function updateActivePreview(): void {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.store-detail__hero .store-detail__action--get {
|
||||
min-width: 54px;
|
||||
min-height: 30px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.store-detail__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -1783,7 +1783,6 @@ Locales["en"] = {
|
||||
selected = "Apps available for your Sky Phone",
|
||||
tabs = { today = "Today", apps = "Apps", games = "Games", search = "Search" },
|
||||
today = {
|
||||
freshDaily = "A fresh selection every day", dailyEdition = "Daily Edition",
|
||||
featured = "Featured today",
|
||||
gameHighlight = "Game highlight", appHighlight = "App highlight",
|
||||
curatedForYou = "Curated for you", editorsChoice = "Editor's Choice",
|
||||
@@ -1804,16 +1803,38 @@ Locales["en"] = {
|
||||
handPicked = "Hand-picked for you", essentialApps = "Essential Apps",
|
||||
essentialGames = "Essential Games",
|
||||
},
|
||||
taglines = {
|
||||
health = "Health, activity and medical information",
|
||||
["weazel-news"] = "Local news and city stories",
|
||||
companies = "Businesses, jobs and services", music = "Songs, playlists and audio",
|
||||
picstagram = "Photo sharing and social feed", feather = "Short posts and city conversations",
|
||||
fliptok = "Short videos and trends", flare = "Social posts and live moments",
|
||||
calendar = "Events, schedules and reminders", radio = "Live radio and team communication",
|
||||
["local-pages"] = "Local businesses and community pages", crewlink = "Crews, members and coordination",
|
||||
phone = "Calls, contacts and voicemail", messages = "Private texts and media",
|
||||
darkchat = "Private messaging and secure groups", garage = "Vehicles, parking and valet",
|
||||
house = "Homes, access and management", map = "Navigation and nearby places",
|
||||
skyride = "City rides and driver booking", banking = "Accounts, transfers and payments",
|
||||
billing = "Invoices and payment requests", mail = "Email and attachments",
|
||||
notes = "Notes, lists and ideas", memos = "Voice recordings and memos",
|
||||
calculator = "Everyday and scientific calculations", camera = "Photos and video capture",
|
||||
clock = "Time, alarms and timers", weather = "Forecasts and current conditions",
|
||||
photos = "Photo and video library", settings = "Phone preferences and security",
|
||||
snake = "Classic arcade snake", memory = "Card matching challenge",
|
||||
["number-merge"] = "Number puzzle and strategy", minesweeper = "Classic logic puzzle",
|
||||
["tower-stack"] = "Precision tower-building game", ["sky-flappy"] = "Fast arcade flying challenge",
|
||||
citymarkt = "Local marketplace and listings", ["neon-drop"] = "Neon block-dropping puzzle",
|
||||
},
|
||||
search = {
|
||||
recommended = "Recommended", discover = "Discover", results = "Search Results",
|
||||
noResults = "No matching apps or games found.", ad = "Ad",
|
||||
noResults = "No Results", noResultsBody = "Try searching for a different app or game.", ad = "Ad",
|
||||
topApps = "Top Apps", topGames = "Top Games", bestApps = "Bestselling Apps",
|
||||
bestGames = "Bestselling Games", productivity = "Productivity", photoVideo = "Photo & Video",
|
||||
},
|
||||
account = {
|
||||
account = "Account", title = "App Management", skyAccount = "Sky Phone Account",
|
||||
apps = "Installed Apps", games = "Games", library = "Your Library", myApps = "My Apps",
|
||||
update = "UPDATE", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
|
||||
downloadedOn = "Downloaded {date}", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
|
||||
uninstallTitle = "Uninstall this app?",
|
||||
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user