ENH - overhaul App Store experience

This commit is contained in:
smx.pusha
2026-08-14 12:53:39 +02:00
parent 3fc0ebb08f
commit 54924daca0
19 changed files with 4165 additions and 71 deletions
+18
View File
@@ -627,6 +627,24 @@ export const BUILTIN_PHONE_APP_IDS: ReadonlySet<BuiltinPhoneAppId> = new Set(
BUILTIN_PHONE_APPS.map((app) => app.id),
)
export const DEFAULT_INSTALLED_PHONE_APP_IDS: ReadonlySet<BuiltinPhoneAppId> =
new Set([
'phone',
'messages',
'calculator',
'camera',
'clock',
'weather',
'mail',
'notes',
'memos',
'photos',
'app-store',
'settings',
'map',
'calendar',
])
export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet<LaunchablePhoneAppId> =
new Set([
'app-store',
+124 -1
View File
@@ -1,7 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
import {
DEFAULT_INSTALLED_PHONE_APP_IDS,
NON_REMOVABLE_PHONE_APP_IDS,
} from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import {
getHomeFolder,
@@ -32,6 +35,53 @@ describe('app store', () => {
vi.useRealTimers()
})
it('installs only the fourteen standard built-in apps by default', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
expect([...DEFAULT_INSTALLED_PHONE_APP_IDS]).toEqual([
'phone',
'messages',
'calculator',
'camera',
'clock',
'weather',
'mail',
'notes',
'memos',
'photos',
'app-store',
'settings',
'map',
'calendar',
])
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
expect(apps.isInstalled(appId)).toBe(true)
}
expect(apps.isInstalled('banking')).toBe(false)
expect(apps.isInstalled('feather')).toBe(false)
expect(apps.isInstalled('snake')).toBe(false)
})
it('removes old automatic apps unless the player installed them', () => {
const apps = useAppStoreStore()
apps.hydrate({
claimedApps: ['feather'],
homeLayout: {
dock: [],
grid: ['banking', 'feather', 'phone'],
hidden: [],
version: 5,
},
})
expect(apps.homeLayout.grid).not.toContain('banking')
expect(apps.homeLayout.grid).toContain('feather')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('hydrates valid launch counts and persists launches with claimed apps', () => {
const apps = useAppStoreStore()
@@ -129,6 +179,79 @@ describe('app store', () => {
})
})
it('fully uninstalls removable apps and allows downloading them again', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.hydrate(null)
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(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
claimedApps: [],
homeLayout: apps.homeLayout,
launchCounts: {},
uninstalledApps: ['calculator'],
})
apps.installApp('calculator')
vi.advanceTimersByTime(3000)
expect(apps.isInstalled('calculator')).toBe(true)
expect(apps.uninstalledApps).toEqual([])
expect(apps.homeLayout.hidden).not.toContain('calculator')
})
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
const apps = useAppStoreStore()
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.isInstalled('phone')).toBe(true)
expect(apps.updatedAppReleases).toEqual({ phone: '2026-08-14' })
})
it('protects system apps 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)
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()
+163 -5
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import {
DEFAULT_INSTALLED_PHONE_APP_IDS,
getPhoneApp,
isExternalPhoneApp,
isPhoneAppId,
@@ -30,6 +31,7 @@ import {
import { nuiCall } from '@/utils/nui'
const INSTALL_DURATION_MS = 3000
const UPDATE_DURATION_MS = 1800
type PendingInstallation = {
deviceImei: string
@@ -37,10 +39,19 @@ 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]
@@ -56,7 +67,9 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) =>
isExternalPhoneApp(app) ? app.defaultInstalled : app.category !== 'games',
isExternalPhoneApp(app)
? app.defaultInstalled
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
).map((app) => app.id)
}
@@ -67,9 +80,39 @@ function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
: NON_REMOVABLE_PHONE_APP_IDS.has(appId)
}
function hasUninstalledBuiltinApp(
layout: unknown,
installedIds: readonly LaunchablePhoneAppId[],
): boolean {
if (!layout || typeof layout !== 'object') return false
const installed = new Set(installedIds)
const source = layout as {
dock?: unknown
grid?: unknown
hidden?: unknown
}
const items = [source.dock, source.grid, source.hidden]
.filter(Array.isArray)
.flat(2)
return items.some((item) => {
const ids =
item && typeof item === 'object' && Array.isArray(item.apps)
? item.apps
: [item]
return ids.some((id: unknown) => {
if (typeof id !== 'string') return false
const app = getPhoneApp(id)
return !!app && !installed.has(app.id) && !isExternalPhoneApp(app)
})
})
}
export const useAppStoreStore = defineStore('app-store', {
state: () => ({
claimedApps: [] as LaunchablePhoneAppId[],
uninstalledApps: [] as LaunchablePhoneAppId[],
updatedAppReleases: {} as Partial<Record<LaunchablePhoneAppId, string>>,
homeLayout: createDefaultHomeLayout(
getDefaultInstalledIds(),
getDefaultGridIds(),
@@ -77,6 +120,7 @@ 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: {
@@ -95,6 +139,9 @@ export const useAppStoreStore = defineStore('app-store', {
return true
},
claimApp(id: LaunchablePhoneAppId): void {
this.uninstalledApps = this.uninstalledApps.filter(
(appId) => appId !== id,
)
if (!this.claimedApps.includes(id)) {
this.claimedApps.push(id)
this.homeLayout = restoreHomeApp(this.homeLayout, id)
@@ -110,6 +157,14 @@ 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)
@@ -188,6 +243,8 @@ export const useAppStoreStore = defineStore('app-store', {
claimedApps?: unknown
homeLayout?: unknown
launchCounts?: unknown
uninstalledApps?: unknown
updatedAppReleases?: unknown
} | null
const layoutVersion =
data?.homeLayout && typeof data.homeLayout === 'object'
@@ -204,9 +261,40 @@ export const useAppStoreStore = defineStore('app-store', {
isValidExternalPhoneAppId(id))),
)
: []
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
if (typeof id !== 'string' || !isPhoneAppId(id)) return false
const app = getPhoneApp(id)
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,
)
const defaults = createDefaultHomeLayout(
installedIds,
getDefaultGridIds(),
@@ -216,6 +304,7 @@ export const useAppStoreStore = defineStore('app-store', {
data?.homeLayout,
defaults,
installedIds,
false,
)
const protectedHiddenAppIds =
this.homeLayout.hidden.filter(isProtectedHomeApp)
@@ -240,6 +329,7 @@ export const useAppStoreStore = defineStore('app-store', {
this.hydrated = true
if (
protectedHiddenAppIds.length ||
removedLegacyDefaults ||
layoutVersion === 2 ||
layoutVersion === 3 ||
layoutVersion === 4
@@ -248,24 +338,30 @@ export const useAppStoreStore = defineStore('app-store', {
}
},
isInstalled(appId: LaunchablePhoneAppId): boolean {
if (this.uninstalledApps.includes(appId)) return false
if (this.claimedApps.includes(appId)) return true
const app = getPhoneApp(appId)
if (!app) return false
return isExternalPhoneApp(app)
? app.defaultInstalled
: app.category !== 'games'
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
},
reconcileCatalog(): void {
const installedIds = [
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
]
].filter((id) => !this.uninstalledApps.includes(id))
const defaults = createDefaultHomeLayout(
installedIds,
getDefaultGridIds(),
getDefaultDockIds(),
)
const previous = JSON.stringify(this.homeLayout)
this.homeLayout = parseHomeLayout(this.homeLayout, defaults, installedIds)
this.homeLayout = parseHomeLayout(
this.homeLayout,
defaults,
installedIds,
false,
)
for (const appId of [...this.homeLayout.hidden]) {
if (isProtectedHomeApp(appId)) {
@@ -406,11 +502,73 @@ export const useAppStoreStore = defineStore('app-store', {
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
this.persist()
},
uninstallApp(appId: LaunchablePhoneAppId): boolean {
const app = getPhoneApp(appId)
if (!app || !this.isInstalled(appId) || !isPhoneAppRemovable(app)) {
return false
}
this.claimedApps = this.claimedApps.filter((id) => id !== appId)
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,
homeLayout: this.homeLayout,
launchCounts: this.launchCounts,
...(this.uninstalledApps.length
? { uninstalledApps: this.uninstalledApps }
: {}),
...(Object.keys(this.updatedAppReleases).length
? { updatedAppReleases: this.updatedAppReleases }
: {}),
})
},
},
+111 -2
View File
@@ -35,6 +35,7 @@ export type PhoneOpenPayload = {
locales?: LocaleTree
memos?: DeviceBootstrap['memos']
notes?: DeviceBootstrap['notes']
player?: DeviceBootstrap['player']
security?: DeviceSecurity
token?: string
}
@@ -1756,15 +1757,116 @@ const defaultLocales: LocaleTree = {
get: 'GET',
open: 'OPEN',
installing: 'Installing',
searchPlaceholder: 'Search apps and games',
player: 'Player',
profileLabel: '{name} profile',
searchPlaceholder: 'Games, apps, stories and more',
appsTitle: 'Built-in Apps',
gamesTitle: 'Games',
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",
discoverTitle: 'Discover {app}',
playTitle: 'Play something new with {app}',
description: '{app} is in the spotlight today on Sky Phone.',
categoryDescription: '{category} · Made for Sky Phone',
moreHighlights: "Today's Highlights",
topToday: 'Top Today',
topTodayDescription: 'Popular picks from todays edition',
oneMoreThing: 'One more thing',
},
filters: {
all: 'All',
social: 'Social',
utilities: 'Utilities',
shopping: 'Shopping',
productivity: 'Productivity',
arcade: 'Arcade',
puzzle: 'Puzzle',
classic: 'Classics',
},
browse: {
categories: 'Store categories',
availableNow: 'Available now',
featureTitle: 'Discover {app}',
featuredPages: 'Featured apps',
handPicked: 'Hand-picked for you',
essentialApps: 'Essential Apps',
essentialGames: 'Essential Games',
},
search: {
recommended: 'Recommended',
discover: 'Discover',
results: 'Search Results',
noResults: 'No matching apps or games found.',
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}',
uninstallTitle: 'Uninstall this app?',
uninstallBody:
'{app} will be removed from this phone. You can download it again from the App Store.',
},
details: {
skyStudios: 'Sky Studios',
share: 'Share app',
openDetails: 'View {app}',
shareReady: 'App information is ready to share.',
shareCopy: 'Take a look at {app} in the App Store.',
ratings: '{count} ratings',
age: 'Age Rating',
years: 'Years',
chart: 'Chart',
whatsNew: "What's New",
version: 'Version {version}',
updatedToday: 'Today',
releaseNotes:
'{app} now includes fresh content, faster loading and several interface improvements.',
preview: 'Preview',
testData: 'Preview data',
previewLive: 'Live Preview',
previousPreview: 'Previous preview',
nextPreview: 'Next preview',
previewOverview: 'Everything important at a glance',
previewCommunity: 'Community',
previewInsights: 'Insights',
today: 'Today',
progress: 'Progress',
communityTitle: 'Together in {app}',
communityBody: 'Discover activity from players around Los Santos.',
featuredMoment: 'Featured moment from today',
weeklyActivity: 'Weekly Activity',
fromLastWeek: 'from last week',
about: 'About this App',
description:
'{app} is a detailed {category} experience made for Sky Phone. Explore its features, connect with the city and keep everything close at hand.',
},
},
phone: {
name: 'Phone',
@@ -3637,7 +3739,8 @@ const defaultLocales: LocaleTree = {
shareCopy: '{count} photos and videos shared from Photos.',
delete: 'Delete Selected',
deleteTitle: 'Delete Selected Media?',
deleteBody: 'The selected photos and videos will be permanently deleted.',
deleteBody:
'The selected photos and videos will be permanently deleted.',
deleted: '{count} media items deleted.',
limit: 'You can select up to 50 media items.',
},
@@ -4101,6 +4204,10 @@ export const usePhoneStore = defineStore('phone', {
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
persistenceGeneration: 0,
persistenceSession: ++nextPersistenceSession,
player: {
firstName: '',
lastName: '',
} as DeviceBootstrap['player'],
security: {
enabled: false,
length: null,
@@ -4133,6 +4240,7 @@ export const usePhoneStore = defineStore('phone', {
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
if (payload.player) this.player = payload.player
this.security = payload.security ?? {
enabled: false,
length: null,
@@ -4142,6 +4250,7 @@ export const usePhoneStore = defineStore('phone', {
},
endDeviceSession(): void {
this.close()
this.player = { firstName: '', lastName: '' }
if (this.deviceSessionToken !== null) {
this.deviceSessionToken = null
this.persistenceGeneration += 1
+6
View File
@@ -14,6 +14,11 @@ export type PhoneDevice = {
sim: PhoneSim | null
}
export type PhonePlayerIdentity = {
firstName: string
lastName: string
}
export type PhoneNotificationDevicePayload = {
imei: string
name: string
@@ -45,6 +50,7 @@ export type DeviceBootstrap = {
device: PhoneDevice
memos: MemoDto[]
notes: Note[]
player: PhonePlayerIdentity
security: DeviceSecurity
token: string
}
+9
View File
@@ -1203,6 +1203,15 @@ label.sky-list-item__row {
opacity: 0.55;
}
.sky-searchbar__suffix {
width: var(--sky-touch-target, 44px);
height: var(--sky-touch-target, 44px);
display: grid;
flex: none;
place-items: center;
color: var(--sky-muted, rgba(0, 0, 0, 0.55));
}
.sky-searchbar__clear {
width: var(--sky-touch-target, 44px);
height: var(--sky-touch-target, 44px);
@@ -160,6 +160,9 @@ function disable(event: MouseEvent): void {
@focus="handleFocus"
@input="handleInput"
/>
<span v-if="$slots.suffix" class="sky-searchbar__suffix">
<slot name="suffix" />
</span>
<button
v-if="clearButton && localValue"
class="sky-searchbar__clear"
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { getDailyHighlights } from '@/utils/appStoreHighlights'
const candidates = [
{ id: 'banking' },
{ id: 'feather' },
{ id: 'snake' },
{ id: 'music' },
{ id: 'picstagram' },
]
describe('daily App Store highlights', () => {
it('keeps the generated order stable throughout one local day', () => {
const morning = getDailyHighlights(candidates, new Date(2026, 7, 14, 8, 15))
const evening = getDailyHighlights(
candidates,
new Date(2026, 7, 14, 22, 45),
)
expect(evening).toEqual(morning)
expect(morning).toHaveLength(candidates.length)
})
it('generates a different curation for the next local day', () => {
const today = getDailyHighlights(candidates, new Date(2026, 7, 14))
const tomorrow = getDailyHighlights(candidates, new Date(2026, 7, 15))
expect(tomorrow.map((app) => app.id)).not.toEqual(
today.map((app) => app.id),
)
})
})
+38
View File
@@ -0,0 +1,38 @@
type HighlightCandidate = {
id: string
}
function localDayNumber(date: Date): number {
return Math.floor(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000,
)
}
function nextRandom(seed: number): number {
return (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0
}
export function getDailyHighlights<T extends HighlightCandidate>(
candidates: readonly T[],
date = new Date(),
): T[] {
const ordered = [...candidates].sort((left, right) =>
left.id.localeCompare(right.id),
)
let seed = localDayNumber(date) >>> 0
for (let index = ordered.length - 1; index > 0; index -= 1) {
seed = nextRandom(seed)
const target = seed % (index + 1)
const current = ordered[index]
ordered[index] = ordered[target]
ordered[target] = current
}
if (ordered.length > 1) {
const offset = localDayNumber(date) % ordered.length
return [...ordered.slice(offset), ...ordered.slice(0, offset)]
}
return ordered
}
+5 -1
View File
@@ -453,12 +453,16 @@ export function parseHomeLayout(
value: unknown,
defaults: HomeLayout,
installedIds: LaunchablePhoneAppId[],
preservePersistedIds = true,
): HomeLayout {
if (!value || typeof value !== 'object') return defaults
const source = value as Partial<Record<keyof HomeLayout, unknown>>
const availableIds = new Set(installedIds)
if (source.version === 3 || source.version === 4 || source.version === 5) {
if (
preservePersistedIds &&
(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 item of collection) {
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
fileURLToPath(new URL('./AppStoreAction.vue', import.meta.url)),
'utf8',
)
describe('AppStoreAction contract', () => {
it('uses an Apple-style cloud download icon instead of GET text', () => {
expect(source).toContain("import { CloudDownload } from 'lucide-vue-next'")
expect(source).toContain('v-if="action === \'get\'"')
expect(source).not.toContain("phone.t('Apps.appStore.get')")
})
it('shows a timed circular installation progress with a center stop mark', () => {
expect(source).toContain('class="app-store-action__progress"')
expect(source).toContain('class="app-store-action__track"')
expect(source).toContain('class="app-store-action__value"')
expect(source).toContain(
'animation: app-store-download-progress 3s linear forwards',
)
expect(source).toMatch(
/\.app-store-action__progress i\s*\{[^}]*width:\s*7px/s,
)
})
})
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { CloudDownload } from 'lucide-vue-next'
import { usePhoneStore } from '@/stores/phone'
defineProps<{
action: 'get' | 'installing' | 'open'
}>()
const phone = usePhoneStore()
</script>
<template>
<span
class="app-store-action"
:class="{ 'app-store-action--icon': action !== 'open' }"
aria-hidden="true"
>
<CloudDownload
v-if="action === 'get'"
class="app-store-action__download"
:size="24"
:stroke-width="1.9"
/>
<span
v-else-if="action === 'installing'"
class="app-store-action__progress"
>
<svg viewBox="0 0 28 28">
<circle class="app-store-action__track" cx="14" cy="14" r="11" />
<circle class="app-store-action__value" cx="14" cy="14" r="11" />
</svg>
<i></i>
</span>
<template v-else>{{ phone.t('Apps.appStore.open') }}</template>
</span>
</template>
<style scoped>
.app-store-action {
display: grid;
place-items: center;
font: inherit;
}
.app-store-action--icon {
width: 28px;
height: 28px;
color: var(--sky-app-accent);
}
.app-store-action__download {
filter: drop-shadow(
0 2px 5px color-mix(in srgb, var(--sky-app-accent) 30%, transparent)
);
}
.app-store-action__progress {
width: 28px;
height: 28px;
position: relative;
display: grid;
place-items: center;
}
.app-store-action__progress svg {
width: 28px;
height: 28px;
position: absolute;
inset: 0;
overflow: visible;
fill: none;
stroke-width: 2.2;
}
.app-store-action__track {
stroke: color-mix(in srgb, var(--sky-app-accent) 24%, transparent);
}
.app-store-action__value {
stroke: var(--sky-app-accent);
stroke-linecap: round;
stroke-dasharray: 69.2;
stroke-dashoffset: 69.2;
transform: rotate(-90deg);
transform-origin: 50% 50%;
animation: app-store-download-progress 3s linear forwards;
}
.app-store-action__progress i {
width: 7px;
height: 7px;
border-radius: 1.5px;
background: var(--sky-app-accent);
}
@keyframes app-store-download-progress {
from {
stroke-dashoffset: 69.2;
}
to {
stroke-dashoffset: 4;
}
}
@media (prefers-reduced-motion: reduce) {
.app-store-action__value {
animation: none;
stroke-dashoffset: 34.6;
}
}
</style>
@@ -23,7 +23,8 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source.match(/<SkyScrollArea/g)).toHaveLength(1)
})
it('renders the Apps, Games and Search glass navigation', () => {
it('renders the Today, Apps, Games and Search glass navigation', () => {
expect(source).toContain("{ id: 'today', icon: Newspaper }")
expect(source).toContain("{ id: 'apps', icon: Grid2X2 }")
expect(source).toContain("{ id: 'games', icon: Gamepad2 }")
expect(source).toContain("{ id: 'search', icon: Search }")
@@ -40,8 +41,75 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(navigationSource).not.toContain('compact')
})
it('renders an Apple-style Search landing page and separate results state', () => {
expect(source).toContain('const hasSearchQuery = computed')
expect(source).toContain('const searchRecommendations = computed')
expect(source).toContain('const searchDiscoverCards = computed')
expect(source).toContain('v-if="!hasSearchQuery"')
expect(source).toContain('class="store-search__recommendations"')
expect(source).toContain('class="store-search__discover-grid"')
expect(source).toContain(
'class="store-list store-list--browse store-list--search-results"',
)
expect(source).not.toContain('.store-list--search-results article > button')
expect(source).not.toContain('<template #suffix>')
expect(source).not.toMatch(/\bMic\b/)
expect(source).toContain('selectSearchDiscovery(card.app)')
expect(source).toMatch(
/\.store-search__discover-grid > button\s*\{[^}]*min-height:\s*124px/s,
)
})
it('opens profile app management with update 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"')
expect(source).toContain('appStore.updateApp(app.id, currentRelease)')
expect(source).toContain(
'appStore.uninstallApp(uninstallCandidate.value.id)',
)
expect(source).toContain('v-if="isPhoneAppRemovable(app)"')
expect(source).toContain(':opened="Boolean(uninstallCandidate)"')
expect(source).toContain('class="store-account__grabber"')
expect(source).toContain('@pointerdown="beginProfileDrag"')
expect(source).toContain('@pointermove="moveProfileDrag"')
expect(source).toContain('@pointerup="endProfileDrag"')
expect(source).toContain('profileDragOffset.value >= 72')
expect(source).not.toContain('<X :size="20"')
})
it('opens App Store details from app and game lists without replacing direct actions', () => {
expect(source).toContain(
"import AppStoreDetail from './AppStoreDetail.vue'",
)
expect(source).toContain(
'const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)',
)
expect(source).toContain('<AppStoreDetail')
expect(source).toContain('@click="openAppDetail(app)"')
expect(source).toContain('@action="handleApp(selectedApp)"')
expect(source).toContain('@back="selectedApp = null"')
expect(source).toContain('@click="selectStoreTab(item.id)"')
expect(source).toContain('scrollElement?.scrollTo({ top: 0 })')
})
it('shares app details through EasyShare and restores shared detail links', () => {
expect(source).toContain(
"import { useEasyShareStore } from '@/stores/easyshare'",
)
expect(source).toContain("appId: 'app-store'")
expect(source).toContain("kind: 'link'")
expect(source).toContain('link: `skyphone://app-store/${app.id}`')
expect(source).toContain('easyShare.open({')
expect(source).toContain('route.query.easyShareId')
expect(source).not.toContain('shareToastOpened')
})
it('keeps one scroll owner and accessible 44px app actions', () => {
expect(source).toContain('<SkyScrollArea class="store-scroll" with-tabbar>')
expect(source).toMatch(
/<SkyScrollArea\s+ref="storeScroll"\s+class="store-scroll"[\s\S]*?with-tabbar\s*>/,
)
expect(source).toMatch(/\.store-scroll\s*\{[^}]*overflow-y:\s*auto/s)
expect(source).not.toMatch(/\.store-scroll\s*\{[^}]*padding\s*:/s)
expect(source).toMatch(/\.store-scroll\s*\{[^}]*padding-top:\s*0/s)
@@ -52,11 +120,101 @@ describe('AppStoreApp Sky navigation contract', () => {
/\.store-scroll\s*\{[^}]*padding-left:\s*calc\(var\(--sky-page-gutter\) \+ var\(--sky-safe-area-left\)\)/s,
)
expect(source).toMatch(
/\.store-list article > button\s*\{[^}]*min-height:\s*var\(--sky-touch-target\)/s,
/\.store-list article > button:not\(\.store-list__detail-link\)\s*\{[^}]*min-height:\s*var\(--sky-touch-target\)/s,
)
expect(source).toContain(':clear-label="phone.t(\'Common.clear\')"')
expect(source).toContain(
':label="phone.t(\'Apps.appStore.searchPlaceholder\')"',
)
expect(source).toContain(
':class="{ \'store-scroll--detail\': selectedApp }"',
)
expect(source).toMatch(
/\.store-scroll--detail\s*\{[^}]*margin-top:\s*var\(--sky-safe-area-top\)/s,
)
})
it('gives interactive download, update and remove buttons subtle pointer feedback', () => {
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,
)
expect(source).toContain(
'.app-store-page :deep(button:has(.app-store-action--icon):hover)',
)
expect(source).toContain(
'.store-account__primary-action:not(:disabled):hover',
)
expect(source).toContain('.store-account__remove:not(:disabled):hover')
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
})
it('builds daily Today cards from non-standard internal apps', () => {
expect(source).toContain(
"ref<'today' | 'apps' | 'games' | 'search'>('today')",
)
expect(source).toContain('getDailyHighlights(')
expect(source).toContain('!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)')
expect(source).toContain('v-else-if="tab === \'today\'"')
expect(source).toContain('class="store-highlight store-highlight--hero"')
expect(source).toContain('class="store-highlight store-highlight--compact"')
expect(source).toContain('class="store-today__edition"')
expect(source).toContain('class="store-ranking"')
expect(source).toContain('class="store-final-pick"')
expect(source).toContain('editorialHighlights')
expect(source).toContain('topToday')
expect(source).toContain('var(--sky-touch-target)')
})
it('opens app details from every Today banner without hijacking app actions', () => {
expect(source.match(/class="store-highlight__detail-link"/g)).toHaveLength(
3,
)
expect(source).toContain('@click="openAppDetail(dailyHighlights[0])"')
expect(source).toContain('@click="openAppDetail(app)"')
expect(source).toContain('@click="openAppDetail(finalHighlight)"')
expect(source).toContain('@click.stop="handleApp(dailyHighlights[0])"')
expect(source).toContain('@click.stop="handleApp(finalHighlight)"')
expect(source).toContain(
'.store-highlight:has(.store-highlight__detail-link:hover)',
)
})
it('opens Top Today apps while keeping their direct app actions separate', () => {
expect(source).toContain('class="store-ranking__detail-link"')
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).not.toContain('.store-ranking__detail-link:hover')
expect(source).toContain(
'button:not(.store-ranking__detail-link):not(:disabled):hover',
)
})
it('builds clean Apps and Games pages with rotating features', () => {
expect(source).not.toContain('class="store-browse__filters"')
expect(source).not.toContain('browseFilter')
expect(source).toContain('class="store-browse-feature"')
expect(source).toContain('class="store-list store-list--browse"')
expect(source).toContain('featuredCandidates')
expect(source).toContain('globalThis.setInterval')
expect(source).toContain('}, 6500)')
expect(source).toContain('globalThis.clearInterval')
expect(source).toMatch(
/\.store-browse__pages button\s*\{[^}]*width:\s*var\(--sky-touch-target\)[^}]*height:\s*var\(--sky-touch-target\)/s,
)
})
it('matches the Photos large header with the player initials profile', () => {
expect(source).toContain('class="app-store-navbar"')
expect(source).toContain(':scroll-el="null"')
expect(source).toContain('<template #right>')
expect(source).toContain('class="app-store-profile"')
expect(source).toContain('phone.player.firstName')
expect(source).toContain('phone.player.lastName')
expect(source).toContain('{{ profileInitials }}')
expect(source).toContain('var(--sky-navbar-large-title-height) - 30px')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
fileURLToPath(new URL('./AppStoreDetail.vue', import.meta.url)),
'utf8',
)
describe('AppStoreDetail contract', () => {
it('renders an Apple-style product page with metadata and direct action', () => {
expect(source).toContain('class="store-detail__toolbar"')
expect(source).toContain('class="store-detail__hero"')
expect(source).toContain('class="store-detail__facts"')
expect(source).toContain('class="store-detail__whats-new"')
expect(source).toContain("emit('action')")
expect(source).toContain("emit('back')")
expect(source).toContain("emit('share')")
expect(source).toContain('<AppStoreAction :action="action" />')
expect(source).toMatch(
/\.store-detail\s*\{[^}]*padding:\s*var\(--sky-space-3\) 0 var\(--sky-space-6\)/s,
)
})
it('generates three app-specific preview panels with visible test data', () => {
expect(source).toContain('class="store-detail__previews"')
expect(source).toContain('store-detail-preview--overview')
expect(source).toContain('store-detail-preview--community')
expect(source).toContain('store-detail-preview--insights')
expect(
source.match(/:src="app\.iconImage"/g)?.length,
).toBeGreaterThanOrEqual(4)
expect(source).toContain('24')
expect(source).toContain('87%')
expect(source).toContain('+18%')
expect(source).toMatch(
/\.store-detail-preview\s*\{[^}]*width:\s*224px[^}]*height:\s*354px/s,
)
})
it('provides accessible previous and next controls for the preview gallery', () => {
expect(source).toContain('ref="previews"')
expect(source).toContain('@scroll.passive="updateActivePreview"')
expect(source).toContain('scrollToPreview(activePreviewIndex - 1)')
expect(source).toContain('scrollToPreview(activePreviewIndex + 1)')
expect(source).toContain('details.previousPreview')
expect(source).toContain('details.nextPreview')
expect(source).toContain('.store-detail__toolbar button:hover')
})
})
+760
View File
@@ -0,0 +1,760 @@
<script setup lang="ts">
import {
Activity,
BarChart3,
ChevronLeft,
ChevronRight,
Share2,
Sparkles,
Star,
Users,
} from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { getPhoneAppLabel, isExternalPhoneApp } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
import AppStoreAction from './AppStoreAction.vue'
const props = defineProps<{
action: 'get' | 'installing' | 'open'
app: LaunchablePhoneAppDefinition
}>()
const emit = defineEmits<{
action: []
back: []
share: []
}>()
const phone = usePhoneStore()
const previews = ref<HTMLElement | null>(null)
const activePreviewIndex = ref(0)
const previewCount = 3
const appName = computed(() => getPhoneAppLabel(props.app, phone.t))
const developer = computed(() =>
isExternalPhoneApp(props.app)
? props.app.developer
: phone.t('Apps.appStore.details.skyStudios'),
)
const rating = computed(() => (4.4 + (props.app.gridOrder % 5) / 10).toFixed(1))
const ratingCount = computed(() =>
new Intl.NumberFormat(phone.lang, { notation: 'compact' }).format(
12400 + props.app.gridOrder * 1371,
),
)
const ageRating = computed(() =>
props.app.category === 'games' ? '12+' : '4+',
)
const chartRank = computed(() => `#${(props.app.gridOrder % 8) + 1}`)
const version = computed(
() => `1.${(props.app.gridOrder % 9) + 1}.${props.app.gridOrder % 5}`,
)
const detailStyle = computed(() => {
const palettes = [
['#2365d8', '#69c7ff'],
['#7a42c2', '#d19dff'],
['#0b8a78', '#60e2c0'],
['#cf5b35', '#ffc06b'],
['#4057b3', '#9baaff'],
] as const
const palette = palettes[props.app.gridOrder % palettes.length]!
return {
'--store-detail-accent': palette[0],
'--store-detail-glow': palette[1],
}
})
function scrollToPreview(index: number): void {
const row = previews.value
if (!row) return
const cards = Array.from(
row.querySelectorAll<HTMLElement>('.store-detail-preview'),
)
const nextIndex = Math.min(cards.length - 1, Math.max(0, index))
const card = cards[nextIndex]
if (!card) return
activePreviewIndex.value = nextIndex
row.scrollTo({
behavior: 'smooth',
left: card.offsetLeft - row.offsetLeft,
})
}
function updateActivePreview(): void {
const row = previews.value
if (!row) return
const cards = Array.from(
row.querySelectorAll<HTMLElement>('.store-detail-preview'),
)
activePreviewIndex.value = cards.reduce((closestIndex, card, index) => {
const closest = cards[closestIndex]
if (!closest) return index
const distance = Math.abs(card.offsetLeft - row.offsetLeft - row.scrollLeft)
const closestDistance = Math.abs(
closest.offsetLeft - row.offsetLeft - row.scrollLeft,
)
return distance < closestDistance ? index : closestIndex
}, 0)
}
</script>
<template>
<section class="store-detail" :style="detailStyle">
<header class="store-detail__toolbar">
<button
type="button"
:aria-label="phone.t('Common.back')"
@click="emit('back')"
>
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.appStore.details.share')"
@click="emit('share')"
>
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
</button>
</header>
<section class="store-detail__hero">
<img :src="app.iconImage" alt="" draggable="false" />
<div>
<h1>{{ appName }}</h1>
<p>{{ developer }}</p>
<button
type="button"
:disabled="action === 'installing'"
@click="emit('action')"
>
<AppStoreAction :action="action" />
</button>
</div>
</section>
<dl class="store-detail__facts">
<div>
<dt>
{{ phone.t('Apps.appStore.details.ratings', { count: ratingCount }) }}
</dt>
<dd>{{ rating }}</dd>
<span class="store-detail__stars" aria-hidden="true">
<Star v-for="star in 5" :key="star" :size="11" fill="currentColor" />
</span>
</div>
<div>
<dt>{{ phone.t('Apps.appStore.details.age') }}</dt>
<dd>{{ ageRating }}</dd>
<span>{{ phone.t('Apps.appStore.details.years') }}</span>
</div>
<div>
<dt>{{ phone.t('Apps.appStore.details.chart') }}</dt>
<dd>{{ chartRank }}</dd>
<span>{{ phone.t(`Home.groups.${app.category}`) }}</span>
</div>
</dl>
<section class="store-detail__whats-new">
<header>
<h2>{{ phone.t('Apps.appStore.details.whatsNew') }}</h2>
<ChevronRight :size="22" :stroke-width="2.4" aria-hidden="true" />
</header>
<div class="store-detail__version">
<span>{{ phone.t('Apps.appStore.details.version', { version }) }}</span>
<span>{{ phone.t('Apps.appStore.details.updatedToday') }}</span>
</div>
<p>
{{
phone.t('Apps.appStore.details.releaseNotes', {
app: appName,
})
}}
</p>
</section>
<section class="store-detail__preview-section">
<header>
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
<div class="store-detail__preview-navigation">
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
<button
type="button"
:aria-label="phone.t('Apps.appStore.details.previousPreview')"
:disabled="activePreviewIndex === 0"
@click="scrollToPreview(activePreviewIndex - 1)"
>
<ChevronLeft :size="17" :stroke-width="2.4" aria-hidden="true" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.appStore.details.nextPreview')"
:disabled="activePreviewIndex === previewCount - 1"
@click="scrollToPreview(activePreviewIndex + 1)"
>
<ChevronRight :size="17" :stroke-width="2.4" aria-hidden="true" />
</button>
</div>
</header>
<div
ref="previews"
class="store-detail__previews"
@scroll.passive="updateActivePreview"
>
<article class="store-detail-preview store-detail-preview--overview">
<div class="store-detail-preview__status">
<span>{{ phone.t('Apps.appStore.details.previewLive') }}</span>
<Activity :size="14" aria-hidden="true" />
</div>
<img :src="app.iconImage" alt="" draggable="false" />
<strong>{{ appName }}</strong>
<small>{{ phone.t('Apps.appStore.details.previewOverview') }}</small>
<div class="store-detail-preview__metrics">
<span><b>24</b>{{ phone.t('Apps.appStore.details.today') }}</span>
<span
><b>87%</b>{{ phone.t('Apps.appStore.details.progress') }}</span
>
</div>
<div class="store-detail-preview__activity">
<span></span><span></span><span></span><span></span><span></span>
</div>
</article>
<article class="store-detail-preview store-detail-preview--community">
<div class="store-detail-preview__status">
<span>{{ phone.t('Apps.appStore.details.previewCommunity') }}</span>
<Users :size="14" aria-hidden="true" />
</div>
<div class="store-detail-preview__avatars">
<span>AM</span><span>JS</span><span>RK</span>
</div>
<strong>{{
phone.t('Apps.appStore.details.communityTitle', { app: appName })
}}</strong>
<small>{{ phone.t('Apps.appStore.details.communityBody') }}</small>
<div class="store-detail-preview__message">
<Sparkles :size="15" aria-hidden="true" />
<span>{{ phone.t('Apps.appStore.details.featuredMoment') }}</span>
</div>
<img :src="app.iconImage" alt="" draggable="false" />
</article>
<article class="store-detail-preview store-detail-preview--insights">
<div class="store-detail-preview__status">
<span>{{ phone.t('Apps.appStore.details.previewInsights') }}</span>
<BarChart3 :size="14" aria-hidden="true" />
</div>
<strong>{{ phone.t('Apps.appStore.details.weeklyActivity') }}</strong>
<div class="store-detail-preview__chart" aria-hidden="true">
<span
v-for="height in [38, 64, 48, 82, 56, 92, 73]"
:key="height"
:style="{ height: `${height}%` }"
></span>
</div>
<div class="store-detail-preview__insight">
<b>+18%</b>
<span>{{ phone.t('Apps.appStore.details.fromLastWeek') }}</span>
</div>
<img :src="app.iconImage" alt="" draggable="false" />
</article>
</div>
</section>
<section class="store-detail__description">
<h2>{{ phone.t('Apps.appStore.details.about') }}</h2>
<p>
{{
phone.t('Apps.appStore.details.description', {
app: appName,
category: phone.t(`Home.groups.${app.category}`),
})
}}
</p>
</section>
</section>
</template>
<style scoped>
.store-detail {
width: 100%;
min-width: 0;
display: grid;
overflow-x: hidden;
gap: var(--sky-space-5);
padding: var(--sky-space-3) 0 var(--sky-space-6);
color: var(--sky-text);
}
.store-detail__preview-section {
min-width: 0;
}
.store-detail__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.store-detail__toolbar button {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 1px solid var(--sky-hairline);
border-radius: 50%;
color: var(--sky-text);
background: var(--sky-surface-variant);
transition:
background-color 100ms ease,
border-color 100ms ease,
box-shadow 100ms ease,
transform 100ms ease;
}
.store-detail__toolbar button:active {
transform: scale(0.94);
}
.store-detail__hero {
display: grid;
grid-template-columns: 116px minmax(0, 1fr);
align-items: center;
gap: var(--sky-space-4);
}
.store-detail__hero > img {
width: 116px;
height: 116px;
border-radius: calc(var(--sky-radius-card) + var(--sky-space-1));
object-fit: cover;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
}
.store-detail__hero > div {
min-width: 0;
}
.store-detail__hero h1 {
margin: 0;
overflow: hidden;
color: var(--sky-text);
font-size: 24px;
line-height: 1.02;
text-overflow: ellipsis;
}
.store-detail__hero p {
margin: 6px 0 12px;
overflow: hidden;
color: var(--sky-muted);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-detail__hero button {
min-width: 72px;
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 0;
border-radius: var(--sky-radius-pill);
padding: 0 var(--sky-space-4);
color: #fff;
background: var(--sky-app-accent);
font-size: 12px;
font-weight: 850;
}
.store-detail__hero button:has(.app-store-action--icon) {
width: var(--sky-touch-target);
min-width: var(--sky-touch-target);
padding: 0;
color: var(--sky-app-accent);
background: transparent;
}
.store-detail__facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 0;
border-top: 1px solid var(--sky-hairline);
border-bottom: 1px solid var(--sky-hairline);
padding: var(--sky-space-3) 0;
}
.store-detail__facts > div {
min-width: 0;
display: flex;
align-items: center;
flex-direction: column;
border-right: 1px solid var(--sky-hairline);
text-align: center;
}
.store-detail__facts > div:last-child {
border-right: 0;
}
.store-detail__facts dt {
overflow: hidden;
color: var(--sky-muted);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.04em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.store-detail__facts dd {
margin: 5px 0 0;
color: var(--sky-text);
font-size: 24px;
font-weight: 700;
}
.store-detail__facts span {
max-width: 100%;
overflow: hidden;
color: var(--sky-muted);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-detail__facts .store-detail__stars {
display: flex;
color: var(--sky-muted);
}
.store-detail__whats-new,
.store-detail__description {
border-bottom: 1px solid var(--sky-hairline);
padding-bottom: var(--sky-space-5);
}
.store-detail__whats-new header,
.store-detail__preview-section > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sky-space-2);
}
.store-detail__whats-new h2,
.store-detail__preview-section h2,
.store-detail__description h2 {
margin: 0;
font-size: var(--sky-font-medium-title);
}
.store-detail__version {
display: flex;
justify-content: space-between;
gap: var(--sky-space-3);
margin-top: var(--sky-space-3);
color: var(--sky-muted);
font-size: 11px;
}
.store-detail__whats-new p,
.store-detail__description p {
margin: var(--sky-space-3) 0 0;
color: var(--sky-text);
font-size: 13px;
line-height: 1.45;
}
.store-detail__preview-navigation {
display: flex;
align-items: center;
gap: 5px;
}
.store-detail__preview-navigation > span {
min-width: 30px;
color: var(--sky-muted);
font-size: 9px;
font-weight: 700;
text-align: center;
}
.store-detail__preview-navigation button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 1px solid var(--sky-hairline);
border-radius: 50%;
padding: 0;
color: var(--sky-text);
background: var(--sky-surface-variant);
transition:
background-color 100ms ease,
color 100ms ease,
transform 100ms ease;
}
.store-detail__preview-navigation button:disabled {
opacity: 0.34;
}
.store-detail__preview-navigation button:active:not(:disabled) {
transform: scale(0.92);
}
.store-detail__previews {
display: flex;
gap: var(--sky-space-3);
margin-right: calc(0px - var(--sky-page-gutter));
overflow-x: auto;
padding: var(--sky-space-3) var(--sky-page-gutter) var(--sky-space-2) 0;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.store-detail__previews::-webkit-scrollbar {
display: none;
}
.store-detail-preview {
width: 224px;
height: 354px;
position: relative;
display: flex;
overflow: hidden;
flex: 0 0 224px;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--sky-radius-card);
padding: var(--sky-space-4);
color: #fff;
background:
radial-gradient(
circle at 78% 12%,
var(--store-detail-glow),
transparent 32%
),
linear-gradient(155deg, var(--store-detail-accent), #101523 118%);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.18);
scroll-snap-align: start;
}
.store-detail-preview::after {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-35deg,
transparent 0 20px,
rgba(255, 255, 255, 0.025) 21px 22px
);
content: '';
pointer-events: none;
}
.store-detail-preview__status {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.store-detail-preview--overview > img {
width: 90px;
height: 90px;
z-index: 1;
align-self: center;
margin: 26px 0 16px;
border-radius: 24px;
object-fit: cover;
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.28);
transform: rotate(-4deg);
}
.store-detail-preview > strong,
.store-detail-preview > small {
position: relative;
z-index: 1;
}
.store-detail-preview > strong {
font-size: 20px;
line-height: 1.08;
}
.store-detail-preview > small {
margin-top: 5px;
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
}
.store-detail-preview__metrics {
z-index: 1;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--sky-space-2);
margin-top: auto;
}
.store-detail-preview__metrics span {
display: flex;
flex-direction: column;
border-radius: var(--sky-radius-control);
padding: var(--sky-space-3);
background: rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.7);
font-size: 9px;
}
.store-detail-preview__metrics b {
color: #fff;
font-size: 17px;
}
.store-detail-preview__activity {
z-index: 1;
height: 28px;
display: flex;
align-items: flex-end;
gap: 5px;
margin-top: var(--sky-space-3);
}
.store-detail-preview__activity span {
width: 20%;
border-radius: var(--sky-radius-pill);
background: rgba(255, 255, 255, 0.42);
}
.store-detail-preview__activity span:nth-child(1) {
height: 42%;
}
.store-detail-preview__activity span:nth-child(2) {
height: 72%;
}
.store-detail-preview__activity span:nth-child(3) {
height: 55%;
}
.store-detail-preview__activity span:nth-child(4) {
height: 92%;
}
.store-detail-preview__activity span:nth-child(5) {
height: 68%;
}
.store-detail-preview__avatars {
z-index: 1;
display: flex;
margin: 42px 0 22px;
}
.store-detail-preview__avatars span {
width: 54px;
height: 54px;
display: grid;
place-items: center;
margin-right: -12px;
border: 3px solid rgba(255, 255, 255, 0.82);
border-radius: 50%;
background: rgba(17, 22, 38, 0.74);
font-size: 12px;
font-weight: 800;
}
.store-detail-preview__message {
z-index: 1;
display: flex;
align-items: center;
gap: var(--sky-space-2);
margin-top: auto;
border-radius: var(--sky-radius-control);
padding: var(--sky-space-3);
background: rgba(255, 255, 255, 0.12);
font-size: 10px;
}
.store-detail-preview--community > img,
.store-detail-preview--insights > img {
width: 42px;
height: 42px;
z-index: 1;
position: absolute;
right: var(--sky-space-4);
bottom: var(--sky-space-4);
border-radius: 12px;
object-fit: cover;
box-shadow: 0 8px 14px rgba(0, 0, 0, 0.24);
}
.store-detail-preview--insights > strong {
margin-top: 36px;
}
.store-detail-preview__chart {
height: 145px;
z-index: 1;
display: flex;
align-items: flex-end;
gap: 7px;
margin-top: var(--sky-space-5);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
padding: 0 var(--sky-space-2);
}
.store-detail-preview__chart span {
width: 14px;
border-radius: 7px 7px 0 0;
background: linear-gradient(180deg, #fff, rgba(255, 255, 255, 0.24));
}
.store-detail-preview__insight {
z-index: 1;
display: flex;
flex-direction: column;
margin-top: var(--sky-space-4);
}
.store-detail-preview__insight b {
font-size: 22px;
}
.store-detail-preview__insight span {
color: rgba(255, 255, 255, 0.68);
font-size: 10px;
}
:global(.phone-app--performance) .store-detail-preview,
:global(.phone-app--performance) .store-detail__hero > img {
box-shadow: none;
}
@media (hover: hover) {
.store-detail__toolbar button:hover {
border-color: rgba(255, 255, 255, 0.16);
background: var(--sky-surface-tint);
box-shadow: 0 7px 16px rgba(0, 0, 0, 0.18);
transform: translateY(-1px);
}
.store-detail__preview-navigation button:hover:not(:disabled) {
color: var(--sky-app-accent);
background: var(--sky-surface-tint);
transform: translateY(-1px);
}
}
@media (prefers-reduced-motion: reduce) {
.store-detail__previews {
scroll-behavior: auto;
}
}
</style>
+4
View File
@@ -8125,6 +8125,10 @@ app.post('/api/:endpoint', (request, response) => {
},
memos: mockMemos,
notes: mockNotes,
player: {
firstName: 'Alex',
lastName: 'Morgan',
},
security: mockSecurity,
token: 'development',
},
+56 -3
View File
@@ -1674,10 +1674,63 @@ Locales["en"] = {
},
appStore = {
name = "App Store", get = "GET", open = "OPEN", installing = "Installing",
searchPlaceholder = "Search apps and games", appsTitle = "Built-in Apps", gamesTitle = "Games",
player = "Player", profileLabel = "{name} profile",
searchPlaceholder = "Games, apps, stories and more", appsTitle = "Built-in Apps", gamesTitle = "Games",
selected = "Apps available for your Sky Phone",
tabs = { apps = "Apps", games = "Games", search = "Search" },
},
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",
discoverTitle = "Discover {app}", playTitle = "Play something new with {app}",
description = "{app} is in the spotlight today on Sky Phone.",
categoryDescription = "{category} · Made for Sky Phone",
moreHighlights = "Today's Highlights", topToday = "Top Today",
topTodayDescription = "Popular picks from todays edition",
oneMoreThing = "One more thing",
},
filters = {
all = "All", social = "Social", utilities = "Utilities", shopping = "Shopping",
productivity = "Productivity", arcade = "Arcade", puzzle = "Puzzle", classic = "Classics",
},
browse = {
categories = "Store categories", availableNow = "Available now",
featureTitle = "Discover {app}", featuredPages = "Featured apps",
handPicked = "Hand-picked for you", essentialApps = "Essential Apps",
essentialGames = "Essential Games",
},
search = {
recommended = "Recommended", discover = "Discover", results = "Search Results",
noResults = "No matching apps or games found.", 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}",
uninstallTitle = "Uninstall this app?",
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
},
details = {
skyStudios = "Sky Studios", share = "Share app", openDetails = "View {app}",
shareReady = "App information is ready to share.", ratings = "{count} ratings",
shareCopy = "Take a look at {app} in the App Store.",
age = "Age Rating", years = "Years", chart = "Chart", whatsNew = "What's New",
version = "Version {version}", updatedToday = "Today",
releaseNotes = "{app} now includes fresh content, faster loading and several interface improvements.",
preview = "Preview", testData = "Preview data", previewLive = "Live Preview",
previousPreview = "Previous preview", nextPreview = "Next preview",
previewOverview = "Everything important at a glance", previewCommunity = "Community",
previewInsights = "Insights", today = "Today", progress = "Progress",
communityTitle = "Together in {app}",
communityBody = "Discover activity from players around Los Santos.",
featuredMoment = "Featured moment from today", weeklyActivity = "Weekly Activity",
fromLastWeek = "from last week", about = "About this App",
description = "{app} is a detailed {category} experience made for Sky Phone. Explore its features, connect with the city and keep everything close at hand.",
},
},
settings = {
name = "Settings", searchPlaceholder = "Search", airplaneMode = "Airplane Mode", streamerMode = "Streamer Mode",
wallpaper = "Wallpaper", on = "On", off = "Off", accountName = "Sky Cloud",
+4
View File
@@ -518,6 +518,10 @@ local function bootstrap(source, security, security_loaded)
} or nil,
notes = SkyPhoneNotes.List(device.account_id, device.imei),
memos = SkyPhoneMemos.List(device.account_id, device.imei),
player = {
firstName = trim(Bridge.Framework.GetFirstname(source)) or "",
lastName = trim(Bridge.Framework.GetLastname(source)) or "",
},
}
end