ENH - install games through App Store

This commit is contained in:
Eichenholz
2026-08-06 21:17:22 +02:00
parent ece0045287
commit b92e3fd51f
9 changed files with 195 additions and 202 deletions
+20 -26
View File
@@ -1808,11 +1808,9 @@ button {
.store-icon {
width: 49px;
height: 49px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 24px;
font-weight: 700;
flex: 0 0 49px;
border-radius: 11px;
object-fit: cover;
}
.store-list article > div:nth-child(2) {
display: flex;
@@ -1823,13 +1821,29 @@ button {
color: #888;
}
.store-list button {
width: 67px;
height: 28px;
display: grid;
place-items: center;
border: 0;
border-radius: 20px;
padding: 5px 15px;
padding: 0 12px;
background: #222;
color: #0a84ff;
font-weight: 700;
}
.store-list button:disabled {
cursor: wait;
}
.store-installing {
color: #0a84ff;
}
.store-empty {
margin: 32px 0;
color: #777;
text-align: center;
font-size: 13px;
}
/* Reference-accurate app surfaces. These remain scoped to the phone resource. */
.calculator-app {
padding: 54px 16px 27px;
@@ -1908,32 +1922,12 @@ button {
overflow-y: auto;
}
.store-title {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.store-title > div {
flex: 1;
display: flex;
align-items: baseline;
gap: 11px;
}
.store-title h1 {
margin: 0;
font-size: 34px;
}
.store-title strong {
color: #777;
font-size: 18px;
}
.store-title > span {
width: 37px;
height: 37px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(145deg, #3a3c46, #1b1c22);
}
.editorial-card {
position: relative;
height: 390px;
+39 -1
View File
@@ -1,5 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStoreStore } from '@/stores/app-store'
@@ -16,6 +16,10 @@ describe('app store', () => {
mocks.saveDeviceNamespace.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('hydrates valid launch counts and persists launches with claimed apps', () => {
const apps = useAppStoreStore()
@@ -31,4 +35,38 @@ describe('app store', () => {
launchCounts: { mail: 4 },
})
})
it('installs an app after showing a three second loading state', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.installApp('snake')
expect(apps.installingApps.snake).toBe(true)
expect(apps.claimedApps).toEqual([])
vi.advanceTimersByTime(2999)
expect(apps.claimedApps).toEqual([])
vi.advanceTimersByTime(1)
expect(apps.installingApps.snake).toBeUndefined()
expect(apps.claimedApps).toEqual(['snake'])
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: ['snake'],
launchCounts: {},
})
})
it('ignores duplicate installation requests and invalid persisted ids', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['memory', 'not-an-app'] })
apps.installApp('snake')
apps.installApp('snake')
vi.advanceTimersByTime(3000)
expect(apps.claimedApps).toEqual(['memory', 'snake'])
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
})
+19 -3
View File
@@ -4,26 +4,42 @@ import { isPhoneAppId } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppId } from '@/types/apps'
const INSTALL_DURATION_MS = 3000
export const useAppStoreStore = defineStore('app-store', {
state: () => ({
claimedApps: [] as string[],
claimedApps: [] as LaunchablePhoneAppId[],
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
}),
actions: {
claimApp(id: string): void {
claimApp(id: LaunchablePhoneAppId): void {
if (!this.claimedApps.includes(id)) {
this.claimedApps.push(id)
this.persist()
}
},
installApp(id: LaunchablePhoneAppId): void {
if (this.claimedApps.includes(id) || this.installingApps[id]) return
this.installingApps[id] = true
globalThis.setTimeout(() => {
this.claimApp(id)
delete this.installingApps[id]
}, INSTALL_DURATION_MS)
},
hydrate(payload: unknown): void {
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')
? data.claimedApps.filter(
(id): id is LaunchablePhoneAppId =>
typeof id === 'string' && isPhoneAppId(id),
)
: []
this.installingApps = {}
this.launchCounts = {}
if (data?.launchCounts && typeof data.launchCounts === 'object') {
for (const [appId, count] of Object.entries(data.launchCounts)) {
+5 -29
View File
@@ -31,42 +31,18 @@ const defaultLocales: LocaleTree = {
Apps: {
appStore: {
name: 'App Store',
eyebrow: 'Discover',
featured: 'Featured',
heroTitle: 'Apps for every day',
heroBody: 'Fresh ideas, built for your life in the city.',
get: 'GET',
open: 'OPEN',
searchPlaceholder: 'Games, Apps, Stories and More',
communityTitle: 'Black Voices and Creators',
communityBody: 'Apps and games from the community',
playing: "What We're Playing",
recommended: 'Recommended for You',
selected: 'Great apps selected by our editors',
installing: 'Installing',
searchPlaceholder: 'Search apps and games',
appsTitle: 'Built-in Apps',
gamesTitle: 'Games',
selected: 'Apps available for your Sky Phone',
tabs: {
today: 'Today',
apps: 'Apps',
games: 'Games',
arcade: 'Arcade',
search: 'Search',
},
catalog: {
orbit: 'Plan your day',
studio: 'Create something new',
trail: 'Explore nearby',
prism: 'A colorful puzzle',
},
card: {
oneEyebrow: 'App of the Day',
oneTitle: 'A universe in your pocket',
oneBody: 'Explore something extraordinary today.',
twoEyebrow: 'Now Trending',
twoTitle: 'Turn up your afternoon',
twoBody: 'Fresh sounds and stories picked for you.',
threeEyebrow: "Editors' Choice",
threeTitle: 'Play without limits',
threeBody: 'A new world is waiting.',
},
},
phone: {
name: 'Phone',
+10 -6
View File
@@ -29,7 +29,9 @@ let pointerStart = 0
let pointerStartedAt = 0
const gridApps = computed(() =>
[...PHONE_APPS].sort((a, b) => a.gridOrder - b.gridOrder),
PHONE_APPS.filter(
(app) => app.category !== 'games' || appStore.claimedApps.includes(app.id),
).sort((a, b) => a.gridOrder - b.gridOrder),
)
const appPages = computed(() =>
paginateItems(gridApps.value, APPS_PER_HOME_PAGE),
@@ -70,11 +72,13 @@ const appGroups = computed(() => {
key: category,
})),
]
return groups.map((group) => ({
...group,
apps: group.apps.slice(0, 3),
moreApps: group.apps.slice(3),
}))
return groups
.filter((group) => group.apps.length > 0)
.map((group) => ({
...group,
apps: group.apps.slice(0, 3),
moreApps: group.apps.slice(3),
}))
})
const alphabeticalGroups = computed(() => {
const groups: Array<{ apps: PhoneAppDefinition[]; letter: string }> = []
+95 -112
View File
@@ -1,144 +1,128 @@
<script setup lang="ts">
import {
Gamepad2,
Grid2X2,
Rocket,
Search,
Sparkles,
UserRound,
} from 'lucide-vue-next'
import { kPreloader } from 'konsta/vue'
import { Gamepad2, Grid2X2, Search } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { PHONE_APPS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const tab = ref<'today' | 'apps' | 'games' | 'arcade' | 'search'>('today')
const router = useRouter()
const tab = ref<'apps' | 'games' | 'search'>('apps')
const query = ref('')
const tabs = [
{ id: 'today', icon: Sparkles },
{ id: 'apps', icon: Grid2X2 },
{ id: 'games', icon: Gamepad2 },
{ id: 'arcade', icon: Rocket },
{ id: 'search', icon: Search },
] as const
const catalog = [
{
id: 'orbit',
title: 'Orbit',
subtitle: 'Apps.appStore.catalog.orbit',
gradient: 'linear-gradient(145deg,#453bd1,#75e8ff)',
},
{
id: 'studio',
title: 'Studio',
subtitle: 'Apps.appStore.catalog.studio',
gradient: 'linear-gradient(145deg,#ff8b4a,#ff2d75)',
},
{
id: 'trail',
title: 'Trail',
subtitle: 'Apps.appStore.catalog.trail',
gradient: 'linear-gradient(145deg,#4bd37b,#147ec1)',
},
{
id: 'prism',
title: 'Prism',
subtitle: 'Apps.appStore.catalog.prism',
gradient: 'linear-gradient(145deg,#ffd84d,#7b36dc)',
},
]
const shown = computed(() =>
catalog.filter((item) =>
item.title.toLowerCase().includes(query.value.toLowerCase()),
),
const catalog = PHONE_APPS.filter((app) => app.id !== 'app-store').sort(
(a, b) => a.gridOrder - b.gridOrder,
)
const date = new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
month: 'long',
}).format(new Date())
const shownApps = computed(() => {
if (tab.value === 'games') {
return catalog.filter((app) => app.category === 'games')
}
if (tab.value === 'apps') {
return catalog.filter((app) => app.category !== 'games')
}
const search = query.value.trim().toLocaleLowerCase(phone.lang)
if (!search) return catalog
return catalog.filter((app) =>
phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(search),
)
})
function isInstalled(app: PhoneAppDefinition): boolean {
return app.category !== 'games' || appStore.claimedApps.includes(app.id)
}
function handleApp(app: PhoneAppDefinition): void {
if (isInstalled(app)) {
if (app.route) void router.push(app.route)
return
}
appStore.installApp(app.id)
}
</script>
<template>
<main class="native-app reference-store">
<section class="store-scroll">
<header class="store-title">
<div>
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
<strong v-if="tab === 'today'">{{ date }}</strong>
</div>
<span><UserRound :size="22" /></span>
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
</header>
<div v-if="tab === 'search'" class="app-search">
<Search :size="17" /><input
<Search :size="17" />
<input
v-model="query"
:placeholder="phone.t('Apps.appStore.searchPlaceholder')"
/>
</div>
<template v-if="tab === 'today'">
<article class="editorial-card editorial-card--sky">
<small>{{ phone.t('Apps.appStore.card.oneEyebrow') }}</small>
<h2>{{ phone.t('Apps.appStore.card.oneTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.card.oneBody') }}</p>
<div class="editorial-orbit"></div>
</article>
<article class="editorial-card editorial-card--music">
<small>{{ phone.t('Apps.appStore.card.twoEyebrow') }}</small>
<h2>{{ phone.t('Apps.appStore.card.twoTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.card.twoBody') }}</p>
<div class="editorial-wave" />
</article>
<div class="store-section-title">
<h2>{{ phone.t('Apps.appStore.communityTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.communityBody') }}</p>
</div>
<article class="editorial-card editorial-card--play">
<small>{{ phone.t('Apps.appStore.card.threeEyebrow') }}</small>
<h2>{{ phone.t('Apps.appStore.card.threeTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.card.threeBody') }}</p>
</article>
</template>
<template v-else>
<article v-if="tab !== 'search'" class="store-feature">
<p>{{ phone.t('Apps.appStore.featured') }}</p>
<h2>{{ phone.t('Apps.appStore.heroTitle') }}</h2>
<span>{{ phone.t('Apps.appStore.heroBody') }}</span>
</article>
<div class="store-section-title">
<h2>
{{
phone.t(
tab === 'games'
? 'Apps.appStore.playing'
: 'Apps.appStore.recommended',
)
}}
</h2>
<p>{{ phone.t('Apps.appStore.selected') }}</p>
</div>
<section class="store-list">
<article v-for="item in shown" :key="item.id">
<div class="store-icon" :style="{ background: item.gradient }">
{{ item.title[0] }}
</div>
<div>
<strong>{{ item.title }}</strong
><small>{{ phone.t(item.subtitle) }}</small>
</div>
<button type="button" @click="appStore.claimApp(item.id)">
<div class="store-section-title">
<h2>
{{
phone.t(
tab === 'games'
? 'Apps.appStore.gamesTitle'
: 'Apps.appStore.appsTitle',
)
}}
</h2>
<p>{{ phone.t('Apps.appStore.selected') }}</p>
</div>
<section class="store-list">
<article v-for="app in shownApps" :key="app.id">
<img
class="store-icon"
:src="app.iconImage"
alt=""
draggable="false"
/>
<div>
<strong>{{ phone.t(app.labelKey) }}</strong>
<small>{{ phone.t(`Home.groups.${app.category}`) }}</small>
</div>
<button
type="button"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${phone.t(app.labelKey)} ${phone.t(
appStore.installingApps[app.id]
? 'Apps.appStore.installing'
: isInstalled(app)
? 'Apps.appStore.open'
: 'Apps.appStore.get',
)}`"
@click="handleApp(app)"
>
<k-preloader
v-if="appStore.installingApps[app.id]"
class="store-installing"
:size="16"
/>
<template v-else>
{{
phone.t(
appStore.claimedApps.includes(item.id)
? 'Apps.appStore.open'
: 'Apps.appStore.get',
isInstalled(app) ? 'Apps.appStore.open' : 'Apps.appStore.get',
)
}}
</button>
</article>
</section>
</template>
</template>
</button>
</article>
<p v-if="shownApps.length === 0" class="store-empty">
{{ phone.t('Home.noApps') }}
</p>
</section>
</section>
<nav class="reference-tabbar">
<button
v-for="item in tabs"
@@ -147,9 +131,8 @@ const date = new Intl.DateTimeFormat(phone.lang, {
type="button"
@click="tab = item.id"
>
<component :is="item.icon" :size="21" /><span>{{
phone.t(`Apps.appStore.tabs.${item.id}`)
}}</span>
<component :is="item.icon" :size="21" />
<span>{{ phone.t(`Apps.appStore.tabs.${item.id}`) }}</span>
</button>
</nav>
</main>
+1 -11
View File
@@ -344,17 +344,7 @@ const deviceData = {
},
apps: {
payload: {
claimedApps: [
'snake',
'memory',
'number-merge',
'minesweeper',
'tower-stack',
'sky-flappy',
'neon-drop',
'citymarkt',
'local-pages',
],
claimedApps: [],
},
revision: 2,
},
+4 -12
View File
@@ -378,18 +378,10 @@ Locales["en"] = {
},
},
appStore = {
name = "App Store", eyebrow = "Discover", featured = "Featured", heroTitle = "Apps for every day",
heroBody = "Fresh ideas, built for your life in the city.", get = "GET", open = "OPEN",
searchPlaceholder = "Games, Apps, Stories and More",
communityTitle = "Black Voices and Creators", communityBody = "Apps and games from the community",
playing = "What We're Playing", recommended = "Recommended for You", selected = "Great apps selected by our editors",
tabs = { today = "Today", apps = "Apps", games = "Games", arcade = "Arcade", search = "Search" },
catalog = { orbit = "Plan your day", studio = "Create something new", trail = "Explore nearby", prism = "A colorful puzzle" },
card = {
oneEyebrow = "App of the Day", oneTitle = "A universe in your pocket", oneBody = "Explore something extraordinary today.",
twoEyebrow = "Now Trending", twoTitle = "Turn up your afternoon", twoBody = "Fresh sounds and stories picked for you.",
threeEyebrow = "Editors' Choice", threeTitle = "Play without limits", threeBody = "A new world is waiting.",
},
name = "App Store", get = "GET", open = "OPEN", installing = "Installing",
searchPlaceholder = "Search apps and games", appsTitle = "Built-in Apps", gamesTitle = "Games",
selected = "Apps available for your Sky Phone",
tabs = { apps = "Apps", games = "Games", search = "Search" },
},
settings = {
name = "Settings", searchPlaceholder = "Search", airplaneMode = "Airplane Mode", streamerMode = "Streamer Mode",
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-CHHmHX1D.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-j2j_4-kJ.css">
<script type="module" crossorigin src="./assets/sky-index-BrXTyVwO.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-BmXo2WLq.css">
</head>
<body>
<div id="app"></div>