Merge branch 'dev' into darkchat

# Conflicts:
#	sky_phone/source/html/index.html
This commit is contained in:
Leon.Schmidt
2026-08-06 22:54:47 +02:00
15 changed files with 426 additions and 238 deletions
+10
View File
@@ -30,6 +30,7 @@ import { useDarkChatStore } from '@/stores/darkchat'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppStoreStore } from '@/stores/app-store'
import { isPhoneAppId } from '@/config/apps'
import { useNotesStore } from '@/stores/notes'
import { useWeatherStore } from '@/stores/weather'
import {
@@ -458,6 +459,15 @@ onMounted(() => {
}
})
watch(
() => route.params.appId,
(appId) => {
if (typeof appId === 'string' && isPhoneAppId(appId)) {
appStore.recordLaunch(appId)
}
},
)
watch(
[() => notifications.requiresAttention, () => calls.activeCall],
([requiresAttention, activeCall]) => {
+36 -33
View File
@@ -344,13 +344,13 @@ button {
left: 0;
width: 100%;
height: 45px;
padding: 11px 26px 0;
padding: 11px 42px 0;
color: #fff;
display: flex;
justify-content: space-between;
align-items: flex-start;
font-size: 12px;
font-weight: 650;
font-weight: 500;
text-shadow: 0 1px 3px #0009;
pointer-events: none;
}
@@ -995,13 +995,10 @@ button {
@keyframes darkchat-panel-in { from { transform: translateY(100%); } }
@keyframes darkchat-modal-in { from { opacity: 0; transform: translateY(18px) scale(.92); } }
.springboard-track {
width: 300%;
height: 100%;
display: flex;
touch-action: pan-y;
transform: translateX(
calc(var(--springboard-page) * -33.333333% + var(--drag-offset))
);
transform: translateX(calc(var(--springboard-offset) + var(--drag-offset)));
transition: transform 0.46s cubic-bezier(0.22, 0.9, 0.24, 1);
}
.springboard--dragging .springboard-track {
@@ -1009,7 +1006,6 @@ button {
}
.springboard-page {
position: relative;
width: 33.333333%;
flex: none;
padding: 61px 22px 138px;
}
@@ -1270,6 +1266,9 @@ button {
}
.springboard-page--library {
padding-top: 62px;
padding-bottom: 25px;
display: flex;
flex-direction: column;
}
.app-library-search,
.app-search {
@@ -1298,14 +1297,22 @@ button {
color: white;
}
.app-library-groups {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 13px 12px;
margin-top: 16px;
padding-bottom: 24px;
overflow-y: auto;
scrollbar-width: none;
transition:
transform 0.35s ease,
opacity 0.35s ease;
}
.app-library-groups::-webkit-scrollbar {
display: none;
}
.app-library-groups--behind {
transform: scale(0.95);
opacity: 0.45;
@@ -2213,11 +2220,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;
@@ -2228,13 +2233,31 @@ 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 {
width: 14px;
height: 14px;
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;
@@ -2313,32 +2336,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;
+16
View File
@@ -92,6 +92,22 @@ describe('app registry', () => {
expect(isPhoneAppId('camera')).toBe(true)
expect(isPhoneAppId('photos')).toBe(true)
expect(isPhoneAppId('clock')).toBe(true)
expect(
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
).toEqual([
'snake',
'memory',
'number-merge',
'minesweeper',
'tower-stack',
'sky-flappy',
'neon-drop',
])
expect(
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['local-pages', 'phone', 'mail'])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+21
View File
@@ -56,6 +56,7 @@ import type {
export const PHONE_APPS: PhoneAppDefinition[] = [
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CalendarApp.vue')),
),
@@ -69,6 +70,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/calendar',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')),
),
@@ -82,6 +84,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/local-pages',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')),
),
@@ -95,6 +98,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/phone',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MessagesApp.vue')),
),
@@ -134,6 +138,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/map',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MailApp.vue')),
),
@@ -147,6 +152,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/mail',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
),
@@ -160,6 +166,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/notes',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')),
),
@@ -173,6 +180,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/calculator',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CameraApp.vue')),
),
@@ -186,6 +194,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/camera',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/ClockApp.vue')),
),
@@ -199,6 +208,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/clock',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/WeatherApp.vue')),
),
@@ -212,6 +222,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/weather',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/GalleryApp.vue')),
),
@@ -225,6 +236,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/photos',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
),
@@ -238,6 +250,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/app-store',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
),
@@ -251,6 +264,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/settings',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SnakeApp.vue')),
),
@@ -264,6 +278,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/snake',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MemoryApp.vue')),
),
@@ -277,6 +292,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/memory',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NumberMergeApp.vue')),
),
@@ -290,6 +306,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/number-merge',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MinesweeperApp.vue')),
),
@@ -303,6 +320,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/minesweeper',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/TowerStackApp.vue')),
),
@@ -316,6 +334,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/tower-stack',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SkyFlappyApp.vue')),
),
@@ -329,6 +348,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/sky-flappy',
},
{
category: 'shopping',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CityMarktApp.vue')),
),
@@ -342,6 +362,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/citymarkt',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NeonDropApp.vue')),
),
+72
View File
@@ -0,0 +1,72 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStoreStore } from '@/stores/app-store'
const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() }))
vi.mock('@/stores/phone', () => ({
usePhoneStore: () => ({
saveDeviceNamespace: mocks.saveDeviceNamespace,
}),
}))
describe('app store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.saveDeviceNamespace.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('hydrates valid launch counts and persists launches with claimed apps', () => {
const apps = useAppStoreStore()
apps.hydrate({
claimedApps: ['snake'],
launchCounts: { mail: 3, invalid: 20, phone: -1 },
})
apps.recordLaunch('mail')
expect(apps.launchCounts).toEqual({ mail: 4 })
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: ['snake'],
launchCounts: { mail: 4 },
})
})
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)
})
})
+44 -4
View File
@@ -1,27 +1,67 @@
import { defineStore } from 'pinia'
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 } | null
const data = payload as {
claimedApps?: unknown
launchCounts?: unknown
} | null
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter((id): id is string => typeof id === 'string')
? 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)) {
if (
isPhoneAppId(appId) &&
typeof count === 'number' &&
Number.isFinite(count) &&
count > 0
) {
this.launchCounts[appId] = Math.floor(count)
}
}
}
},
recordLaunch(appId: LaunchablePhoneAppId): void {
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
this.persist()
},
persist(): void {
usePhoneStore().saveDeviceNamespace('apps', {
claimedApps: this.claimedApps,
launchCounts: this.launchCounts,
})
},
},
+16 -32
View File
@@ -135,42 +135,18 @@ const defaultLocales: LocaleTree = {
},
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',
@@ -1039,7 +1015,15 @@ const defaultLocales: LocaleTree = {
noApps: 'No apps found',
page: 'Page',
pages: 'Home screen pages',
groups: { 0: 'Suggestions', 1: 'Recently Added', other: 'Other' },
groups: {
suggestions: 'Suggestions',
recentlyAdded: 'Recently Added',
games: 'Games',
productivity: 'Productivity',
shopping: 'Shopping',
social: 'Social Networks',
utilities: 'Utilities',
},
widgets: {
label: 'Widgets',
weather: { city: 'Los Santos', condition: 'Partly Cloudy' },
@@ -1128,8 +1112,8 @@ export const usePhoneStore = defineStore('phone', {
})
namespaceQueues.set(namespace, tracked)
},
setCurrentPage(page: number): void {
this.currentPage = clampPage(page)
setCurrentPage(page: number, pageCount?: number): void {
this.currentPage = clampPage(page, pageCount)
},
setCameraLandscape(landscape: boolean): void {
this.cameraLandscape = landscape
+8
View File
@@ -27,6 +27,13 @@ export type PhoneAppId =
export type LaunchablePhoneAppId = PhoneAppId
export type PhoneAppCategory =
| 'games'
| 'productivity'
| 'shopping'
| 'social'
| 'utilities'
export type AppLaunchOrigin = {
borderRadius: number
scaleX: number
@@ -36,6 +43,7 @@ export type AppLaunchOrigin = {
}
export type PhoneAppDefinition = {
category: PhoneAppCategory
component: Component | null
dockOrder: number | null
gridOrder: number
+13 -1
View File
@@ -1,9 +1,21 @@
import { describe, expect, it } from 'vitest'
import { clampPage } from './pages'
import { clampPage, paginateItems } from './pages'
describe('page clamping', () => {
it('keeps pages in range', () => {
expect(clampPage(-4)).toBe(0)
expect(clampPage(1)).toBe(1)
expect(clampPage(9)).toBe(2)
})
it('splits overflowing app grids into additional pages', () => {
expect(
paginateItems(
Array.from({ length: 21 }, (_, index) => index),
20,
),
).toEqual([
Array.from({ length: 20 }, (_, index) => index),
[20],
])
})
})
+9
View File
@@ -1,5 +1,14 @@
export const SPRINGBOARD_PAGE_COUNT = 3
export function paginateItems<T>(items: readonly T[], pageSize: number): T[][] {
if (!Number.isInteger(pageSize) || pageSize <= 0) return []
const pages: T[][] = []
for (let index = 0; index < items.length; index += pageSize) {
pages.push(items.slice(index, index + pageSize))
}
return pages
}
export function clampPage(
page: number,
pageCount = SPRINGBOARD_PAGE_COUNT,
+76 -30
View File
@@ -5,11 +5,21 @@ import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import SpringboardWidgets from '@/components/SpringboardWidgets.vue'
import { PHONE_APPS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
import { clampPage, SPRINGBOARD_PAGE_COUNT } from '@/utils/pages'
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
import { paginateItems } from '@/utils/pages'
const APPS_PER_HOME_PAGE = 20
const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
'games',
'productivity',
'social',
'utilities',
'shopping',
]
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const searchQuery = ref('')
const searchFocused = ref(false)
const showAllApps = ref(false)
@@ -19,7 +29,17 @@ 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),
)
const pageCount = computed(() => appPages.value.length + 2)
const libraryPage = computed(() => pageCount.value - 1)
const isAppPage = computed(
() => phone.currentPage > 0 && phone.currentPage < libraryPage.value,
)
const dockApps = computed(() =>
PHONE_APPS.filter((app) => app.dockOrder !== null).sort(
@@ -34,14 +54,31 @@ const filteredApps = computed(() => {
)
})
const appGroups = computed(() => {
const groups: PhoneAppDefinition[][] = []
for (let index = 0; index < gridApps.value.length; index += 3) {
groups.push(gridApps.value.slice(index, index + 3))
}
return groups.map((apps, index) => ({
apps,
moreApps: groups[(index + 1) % groups.length] ?? [],
}))
const suggestions = [...gridApps.value]
.sort(
(a, b) =>
(appStore.launchCounts[b.id] ?? 0) -
(appStore.launchCounts[a.id] ?? 0) || a.gridOrder - b.gridOrder,
)
.slice(0, 7)
const recentlyAdded = [...gridApps.value]
.sort((a, b) => b.gridOrder - a.gridOrder)
.slice(0, 7)
const groups = [
{ apps: suggestions, key: 'suggestions' },
{ apps: recentlyAdded, key: 'recentlyAdded' },
...APP_LIBRARY_CATEGORIES.map((category) => ({
apps: gridApps.value.filter((app) => app.category === category),
key: category,
})),
]
return groups
.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 }> = []
@@ -57,8 +94,10 @@ const alphabeticalGroups = computed(() => {
})
const trackStyle = computed(() => ({
'--drag-offset': `${dragOffset.value}px`,
'--springboard-page': phone.currentPage,
'--springboard-offset': `${(-phone.currentPage * 100) / pageCount.value}%`,
width: `${pageCount.value * 100}%`,
}))
const pageStyle = computed(() => ({ width: `${100 / pageCount.value}%` }))
function onPointerDown(event: PointerEvent): void {
const target = event.target as HTMLElement
@@ -80,7 +119,10 @@ function finishPointer(event: PointerEvent): void {
const elapsed = Math.max(1, Date.now() - pointerStartedAt)
const velocity = Math.abs(distance) / elapsed
if (Math.abs(distance) > 48 || velocity > 0.45) {
phone.setCurrentPage(phone.currentPage + (distance < 0 ? 1 : -1))
phone.setCurrentPage(
phone.currentPage + (distance < 0 ? 1 : -1),
pageCount.value,
)
}
dragging.value = false
dragOffset.value = 0
@@ -116,22 +158,27 @@ function openAllApps(): void {
>
<section
class="springboard-page springboard-page--widgets"
:style="pageStyle"
:aria-label="phone.t('Home.widgets.label')"
>
<SpringboardWidgets />
</section>
<section
v-for="(apps, pageIndex) in appPages"
:key="`apps-${pageIndex}`"
class="springboard-page springboard-page--apps"
:style="pageStyle"
:aria-label="phone.t('Home.apps')"
>
<div class="app-grid">
<AppIcon v-for="app in gridApps" :key="app.id" :app="app" />
<AppIcon v-for="app in apps" :key="app.id" :app="app" />
</div>
</section>
<section
class="springboard-page springboard-page--library"
:style="pageStyle"
:aria-label="phone.t('Home.appLibrary')"
>
<div
@@ -160,8 +207,8 @@ function openAllApps(): void {
:class="{ 'app-library-groups--behind': showAllApps }"
>
<article
v-for="(group, index) in appGroups"
:key="index"
v-for="group in appGroups"
:key="group.key"
class="app-library-group"
>
<div class="app-library-group__icons">
@@ -173,6 +220,7 @@ function openAllApps(): void {
:show-label="false"
/>
<button
v-if="group.moreApps.length"
class="app-library-more"
type="button"
:aria-label="phone.t('Home.allApps')"
@@ -187,9 +235,7 @@ function openAllApps(): void {
/>
</button>
</div>
<span>{{
phone.t(index < 2 ? `Home.groups.${index}` : 'Home.groups.other')
}}</span>
<span>{{ phone.t(`Home.groups.${group.key}`) }}</span>
</article>
</div>
@@ -223,11 +269,7 @@ function openAllApps(): void {
</div>
<Transition name="dock">
<nav
v-if="phone.currentPage === 1"
class="app-dock"
:aria-label="phone.t('Home.dock')"
>
<nav v-if="isAppPage" class="app-dock" :aria-label="phone.t('Home.dock')">
<AppIcon
v-for="app in dockApps"
:key="app.id"
@@ -237,14 +279,18 @@ function openAllApps(): void {
</nav>
</Transition>
<nav class="page-indicator" :aria-label="phone.t('Home.pages')">
<nav
v-if="isAppPage && appPages.length > 1"
class="page-indicator"
:aria-label="phone.t('Home.pages')"
>
<button
v-for="page in SPRINGBOARD_PAGE_COUNT"
:key="page"
v-for="(_, pageIndex) in appPages"
:key="pageIndex"
type="button"
:class="{ active: phone.currentPage === page - 1 }"
:aria-label="`${phone.t('Home.page')} ${page}`"
@click="phone.setCurrentPage(clampPage(page - 1))"
:class="{ active: phone.currentPage === pageIndex + 1 }"
:aria-label="`${phone.t('Home.page')} ${pageIndex + 1}`"
@click="phone.setCurrentPage(pageIndex + 1, pageCount)"
></button>
</nav>
</section>
+94 -112
View File
@@ -1,144 +1,127 @@
<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"
/>
<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 +130,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
@@ -477,17 +477,7 @@ const deviceData = {
},
apps: {
payload: {
claimedApps: [
'snake',
'memory',
'number-merge',
'minesweeper',
'tower-stack',
'sky-flappy',
'neon-drop',
'citymarkt',
'local-pages',
],
claimedApps: [],
},
revision: 2,
},
+8 -13
View File
@@ -26,7 +26,10 @@ Locales["en"] = {
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", page = "Page", pages = "Home screen pages",
groups = { ["0"] = "Suggestions", ["1"] = "Recently Added", other = "Other" },
groups = {
suggestions = "Suggestions", recentlyAdded = "Recently Added", games = "Games",
productivity = "Productivity", shopping = "Shopping", social = "Social Networks", utilities = "Utilities",
},
widgets = {
label = "Widgets",
weather = { city = "Los Santos", condition = "Partly Cloudy" },
@@ -429,18 +432,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-BP8rWcLp.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DyRptftA.css">
<script type="module" crossorigin src="./assets/sky-index-BFdQCwdx.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-Caunuv-C.css">
</head>
<body>
<div id="app"></div>