ENH - frame

This commit is contained in:
Eichenholz
2026-08-03 18:42:48 +02:00
parent eb915b9334
commit 3c25331bcb
9 changed files with 1018 additions and 213 deletions
+38 -4
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { kApp } from 'konsta/vue'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, type CSSProperties } from 'vue'
import { useRoute } from 'vue-router'
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -17,6 +18,13 @@ type AppMessage = {
const phone = usePhoneStore()
const route = useRoute()
const isAppRoute = computed(() => route.name === 'app')
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
const phoneDeviceStyle = computed<CSSProperties>(() => ({
'--phone-scale': phone.preferences.settings.phoneScale / 100,
}))
const phoneFrameImage = computed(
() => PHONE_FRAME_IMAGES[phone.preferences.settings.frame],
)
function onMessage(event: MessageEvent<AppMessage>): void {
if (event.data?.type === 'app:open') {
@@ -32,9 +40,15 @@ function onKeydown(event: KeyboardEvent): void {
void nuiCall('close')
}
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
phone.setSystemDarkMode(event.matches)
}
onMounted(() => {
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
systemColorScheme.addEventListener('change', onSystemColorSchemeChange)
phone.setSystemDarkMode(systemColorScheme.matches)
void nuiCall('ui:ready')
if (import.meta.env.DEV) phone.open()
})
@@ -42,15 +56,28 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
})
</script>
<template>
<main v-if="phone.isOpen" class="phone-stage">
<section class="phone-device" :aria-label="phone.t('Common.phone')">
<div class="phone-device__island" aria-hidden="true"></div>
<section
class="phone-device"
:style="phoneDeviceStyle"
:aria-label="phone.t('Common.phone')"
>
<div class="phone-screen" :class="{ 'phone-screen--app': isAppRoute }">
<k-app theme="ios" dark safe-areas class="phone-app">
<k-app
theme="ios"
:dark="phone.isDarkMode"
safe-areas
class="phone-app"
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
}"
>
<PhoneStatusBar />
<SpringboardView />
<RouterView v-slot="{ Component }">
@@ -61,6 +88,13 @@ onBeforeUnmount(() => {
<PhoneHomeIndicator />
</k-app>
</div>
<img
class="phone-device__frame"
:src="phoneFrameImage"
alt=""
aria-hidden="true"
draggable="false"
/>
</section>
</main>
</template>
+131 -19
View File
@@ -43,35 +43,35 @@ button {
width: min(39vh, 390px);
aspect-ratio: 390/844;
padding: min(1.05vh, 10px);
overflow: hidden;
overflow: visible;
pointer-events: auto;
background: linear-gradient(145deg, #41434a, #090a0d 38%, #2b2d32);
border: 1px solid #ffffff38;
background: transparent;
border: 0;
border-radius: min(5.2vh, 52px);
box-shadow:
0 4vh 10vh #0009,
inset 0 0 0 1px #ffffff1f;
transform: scale(var(--phone-scale, 1));
transition: transform 0.25s ease;
box-shadow: none;
filter: drop-shadow(0 4vh 10vh #0009);
}
.phone-device__island {
.phone-device__frame {
position: absolute;
top: min(2.05vh, 20px);
left: 50%;
z-index: 80;
width: min(9.2vh, 92px);
height: min(2.85vh, 28px);
transform: translateX(-50%);
background: #050506;
border-radius: 999px;
box-shadow: inset 0 -1px 1px #ffffff12;
z-index: 100;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
}
.phone-screen {
position: relative;
container-type: size;
width: 100%;
height: 100%;
margin: auto;
margin-top: 0.9vh;
width: 94%;
height: 97%;
overflow: hidden;
background: #08080a;
border-radius: min(4.25vh, 42px);
border-radius: 4vh;
}
.phone-app {
position: absolute !important;
@@ -1763,6 +1763,118 @@ button {
.reference-settings .wallpaper-picker button {
height: 105px;
}
.konsta-settings {
padding: 0;
overflow: hidden;
background: #000;
color: #fff;
}
.settings-konsta-list {
margin-top: 17px !important;
margin-bottom: 17px !important;
}
.settings-konsta-navbar {
z-index: 8;
margin-top: 44px;
flex: none;
}
.settings-subpage-scroll {
height: calc(100% - 88px);
padding: 1px 0 48px;
overflow-y: auto;
}
.settings-app-icon {
width: 32px;
height: 32px;
display: block;
object-fit: contain;
}
.settings-notification-app {
padding: 20px 20px 3px;
display: flex;
flex-direction: column;
align-items: center;
gap: 9px;
}
.settings-notification-app img {
width: 66px;
height: 66px;
object-fit: contain;
}
.settings-notification-app strong {
font-size: 17px;
}
.settings-range-row,
.settings-scale-row {
width: 100%;
display: flex;
align-items: center;
gap: 11px;
}
.settings-range-row > div,
.settings-scale-row > div {
flex: 1;
}
.settings-scale-row > span {
font-size: 13px;
}
.settings-scale-row > strong {
font-size: 21px;
}
.settings-check {
color: #0a84ff;
}
.settings-frame-picker {
margin: 0 16px 22px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 11px;
}
.settings-frame-picker button {
min-width: 0;
padding: 9px 6px 7px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
border: 2px solid transparent;
border-radius: 13px;
background: #1c1c1e;
color: inherit;
font-size: 10px;
}
.settings-frame-picker button.active {
border-color: #0a84ff;
}
.settings-frame-picker img {
width: 40px;
height: 82px;
object-fit: fill;
}
.settings-wallpaper-picker {
margin-top: 10px;
}
.phone-app--light .konsta-settings {
background: #f2f2f7;
color: #000;
}
.phone-app--light .settings-search,
.phone-app--light .settings-account,
.phone-app--light .settings-frame-picker button {
background: #fff;
}
.phone-app--light .settings-search {
color: #8e8e93;
}
.phone-app--light .settings-search input {
color: #000;
}
.phone-app--light .settings-account small {
color: #666;
}
.phone-screen--app .phone-app--light .phone-status-bar {
color: #000;
}
* {
scrollbar-width: none;
}
+14
View File
@@ -0,0 +1,14 @@
import blackFrame from '@/assets/img/frames/black.webp'
import blueFrame from '@/assets/img/frames/blue.webp'
import greenFrame from '@/assets/img/frames/green.webp'
import lavenderFrame from '@/assets/img/frames/lavender.webp'
import whiteFrame from '@/assets/img/frames/white.webp'
import type { PhoneFrameId } from '@/utils/preferences'
export const PHONE_FRAME_IMAGES: Record<PhoneFrameId, string> = {
black: blackFrame,
blue: blueFrame,
green: greenFrame,
lavender: lavenderFrame,
white: whiteFrame,
}
+81 -25
View File
@@ -1,9 +1,11 @@
import { defineStore } from 'pinia'
import type { AppLaunchOrigin } from '@/types/apps'
import type { AppLaunchOrigin, PhoneAppId } from '@/types/apps'
import { clampPage } from '@/utils/pages'
import {
readPhonePreferences,
type AppNotificationPreferences,
type PhonePreferencesV1,
type WallpaperId,
writePhonePreferences,
} from '@/utils/preferences'
@@ -132,32 +134,61 @@ const defaultLocales: LocaleTree = {
settings: {
name: 'Settings',
searchPlaceholder: 'Search',
wifi: 'Wi-Fi',
bluetooth: 'Bluetooth',
airplaneMode: 'Airplane Mode',
streamerMode: 'Streamer Mode',
wallpaper: 'Wallpaper',
on: 'On',
off: 'Off',
accountName: 'Sky Citizen',
accountDetail: 'Apple ID, iCloud, Media & Purchases',
mobileServices: 'Mobile Services',
personalHotspot: 'Personal Hotspot',
vpn: 'VPN',
notifications: 'Notifications',
sounds: 'Sounds & Haptics',
focus: 'Focus',
screenTime: 'Screen Time',
general: 'General',
display: 'Display & Brightness',
homeScreen: 'Home Screen & App Library',
accessibility: 'Accessibility',
battery: 'Battery',
privacy: 'Privacy & Security',
passwords: 'Passwords',
general: 'General Settings',
appearance: 'Appearance',
allowNotifications: 'Allow Notifications',
notificationSounds: 'Sounds',
ringtoneVolume: 'Ringtone Volume',
notificationVolume: 'Notification Volume',
ringtone: 'Ringtone',
notificationSound: 'Notification Sound',
appearanceMode: 'Appearance Mode',
automatic: 'Automatic',
light: 'Light',
dark: 'Dark',
phoneScale: 'Phone Scale',
phoneFrame: 'Phone Frame',
about: 'About',
deviceName: 'Device Name',
deviceNameValue: 'Sky Phone',
softwareVersion: 'Software Version',
language: 'Language',
languageValue: 'English',
localStorage: 'Local Storage',
localStorageValue: 'On Device',
back: 'Settings',
wallpaperPicker: 'Built-in Wallpapers',
toggle: {
wifi: 'Toggle Wi-Fi',
bluetooth: 'Toggle Bluetooth',
airplaneMode: 'Toggle Airplane Mode',
streamerMode: 'Toggle Streamer Mode',
notifications: 'Toggle notifications for {app}',
notificationSounds: 'Toggle notification sounds for {app}',
},
frames: {
black: 'Black',
blue: 'Blue',
green: 'Green',
lavender: 'Lavender',
white: 'White',
},
ringtones: {
skyline: 'Skyline',
horizon: 'Horizon',
pulse: 'Pulse',
},
notificationSoundsList: {
chime: 'Chime',
signal: 'Signal',
soft: 'Soft',
},
wallpapers: {
midnight: 'Midnight wallpaper',
@@ -224,7 +255,15 @@ export const usePhoneStore = defineStore('phone', {
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: readPhonePreferences(),
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
}),
getters: {
isDarkMode(state): boolean {
if (state.preferences.settings.appearanceMode === 'dark') return true
if (state.preferences.settings.appearanceMode === 'light') return false
return state.systemDarkMode
},
},
actions: {
close(): void {
this.isOpen = false
@@ -240,25 +279,42 @@ export const usePhoneStore = defineStore('phone', {
setLaunchOrigin(origin: AppLaunchOrigin | null): void {
this.launchOrigin = origin
},
setSetting(
key: Exclude<keyof typeof this.preferences.settings, 'wallpaper'>,
setAppNotification(
appId: PhoneAppId,
key: keyof AppNotificationPreferences,
value: boolean,
): void {
this.preferences.settings.notifications[appId][key] = value
writePhonePreferences(this.preferences)
},
setPreference<K extends keyof PhonePreferencesV1['settings']>(
key: K,
value: PhonePreferencesV1['settings'][K],
): void {
this.preferences.settings[key] = value
writePhonePreferences(this.preferences)
},
setSystemDarkMode(value: boolean): void {
this.systemDarkMode = value
},
setWallpaper(wallpaper: WallpaperId): void {
this.preferences.settings.wallpaper = wallpaper
writePhonePreferences(this.preferences)
},
t(path: string): string {
t(path: string, replacements: Record<string, string> = {}): string {
const translated = getByPath(this.locales, path)
const fallback = getByPath(defaultLocales, path)
return typeof translated === 'string'
? translated
: typeof fallback === 'string'
? fallback
: path
const value =
typeof translated === 'string'
? translated
: typeof fallback === 'string'
? fallback
: path
return Object.entries(replacements).reduce(
(result, [key, replacement]) =>
result.split(`{${key}}`).join(replacement),
value,
)
},
},
})
+38 -2
View File
@@ -11,10 +11,46 @@ describe('preferences', () => {
const value = parsePhonePreferences(
JSON.stringify({
version: 1,
settings: { wifi: false, wallpaper: 'ember' },
settings: {
appearanceMode: 'light',
notificationVolume: 45,
notifications: {
camera: { enabled: false, sounds: false },
},
phoneScale: 110,
wallpaper: 'ember',
},
}),
)
expect(value.settings.wifi).toBe(false)
expect(value.settings.appearanceMode).toBe('light')
expect(value.settings.notificationVolume).toBe(45)
expect(value.settings.notifications.camera).toEqual({
enabled: false,
sounds: false,
})
expect(value.settings.notifications.clock.enabled).toBe(true)
expect(value.settings.phoneScale).toBe(110)
expect(value.settings.wallpaper).toBe('ember')
})
it('clamps ranges and rejects unknown choices', () => {
const value = parsePhonePreferences(
JSON.stringify({
version: 1,
settings: {
appearanceMode: 'neon',
frame: 'gold',
notificationVolume: -10,
phoneScale: 500,
ringtoneVolume: 120,
},
}),
)
expect(value.settings.appearanceMode).toBe('automatic')
expect(value.settings.frame).toBe('black')
expect(value.settings.notificationVolume).toBe(0)
expect(value.settings.phoneScale).toBe(115)
expect(value.settings.ringtoneVolume).toBe(100)
})
})
+146 -34
View File
@@ -1,34 +1,126 @@
import type { PhoneAppId } from '@/types/apps'
export const PHONE_PREFERENCES_KEY = 'sky_phone.preferences.v1'
export type WallpaperId = 'midnight' | 'aurora' | 'ember'
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
export const PHONE_FRAME_IDS = [
'black',
'blue',
'green',
'lavender',
'white',
] as const
export const RINGTONE_IDS = ['skyline', 'horizon', 'pulse'] as const
export const NOTIFICATION_SOUND_IDS = ['chime', 'signal', 'soft'] as const
export const WALLPAPER_IDS = ['midnight', 'aurora', 'ember'] as const
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
export type PhoneFrameId = (typeof PHONE_FRAME_IDS)[number]
export type RingtoneId = (typeof RINGTONE_IDS)[number]
export type NotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
export type WallpaperId = (typeof WALLPAPER_IDS)[number]
export type AppNotificationPreferences = {
enabled: boolean
sounds: boolean
}
export type PhonePreferencesV1 = {
settings: {
airplaneMode: boolean
bluetooth: boolean
cellular: boolean
personalHotspot: boolean
vpn: boolean
appearanceMode: AppearanceMode
frame: PhoneFrameId
notificationSound: NotificationSoundId
notificationVolume: number
notifications: Record<PhoneAppId, AppNotificationPreferences>
phoneScale: number
ringtone: RingtoneId
ringtoneVolume: number
streamerMode: boolean
wallpaper: WallpaperId
wifi: boolean
}
version: 1
}
const DEFAULT_APP_NOTIFICATIONS: Record<
PhoneAppId,
AppNotificationPreferences
> = {
'app-store': { enabled: true, sounds: true },
calculator: { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
photos: { enabled: true, sounds: true },
settings: { enabled: true, sounds: true },
}
export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
settings: {
airplaneMode: false,
bluetooth: true,
cellular: true,
personalHotspot: false,
vpn: false,
appearanceMode: 'automatic',
frame: 'black',
notificationSound: 'chime',
notificationVolume: 70,
notifications: DEFAULT_APP_NOTIFICATIONS,
phoneScale: 100,
ringtone: 'skyline',
ringtoneVolume: 80,
streamerMode: false,
wallpaper: 'midnight',
wifi: true,
},
version: 1,
}
export const WALLPAPER_IDS: WallpaperId[] = ['midnight', 'aurora', 'ember']
function readBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
}
function readNumber(
value: unknown,
fallback: number,
minimum: number,
maximum: number,
): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.min(maximum, Math.max(minimum, value))
: fallback
}
function readChoice<T extends string>(
value: unknown,
choices: readonly T[],
fallback: T,
): T {
return typeof value === 'string' && choices.includes(value as T)
? (value as T)
: fallback
}
function readNotifications(
value: unknown,
): Record<PhoneAppId, AppNotificationPreferences> {
const source =
value && typeof value === 'object'
? (value as Partial<
Record<PhoneAppId, Partial<AppNotificationPreferences>>
>)
: {}
const notifications = structuredClone(DEFAULT_APP_NOTIFICATIONS)
for (const appId of Object.keys(notifications) as PhoneAppId[]) {
notifications[appId] = {
enabled: readBoolean(
source[appId]?.enabled,
DEFAULT_APP_NOTIFICATIONS[appId].enabled,
),
sounds: readBoolean(
source[appId]?.sounds,
DEFAULT_APP_NOTIFICATIONS[appId].sounds,
),
}
}
return notifications
}
export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
if (!raw) return structuredClone(DEFAULT_PHONE_PREFERENCES)
@@ -43,28 +135,48 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
const defaults = DEFAULT_PHONE_PREFERENCES.settings
return {
settings: {
airplaneMode:
typeof settings.airplaneMode === 'boolean'
? settings.airplaneMode
: defaults.airplaneMode,
bluetooth:
typeof settings.bluetooth === 'boolean'
? settings.bluetooth
: defaults.bluetooth,
cellular:
typeof settings.cellular === 'boolean'
? settings.cellular
: defaults.cellular,
personalHotspot:
typeof settings.personalHotspot === 'boolean'
? settings.personalHotspot
: defaults.personalHotspot,
vpn: typeof settings.vpn === 'boolean' ? settings.vpn : defaults.vpn,
wallpaper: WALLPAPER_IDS.includes(settings.wallpaper as WallpaperId)
? (settings.wallpaper as WallpaperId)
: defaults.wallpaper,
wifi:
typeof settings.wifi === 'boolean' ? settings.wifi : defaults.wifi,
airplaneMode: readBoolean(settings.airplaneMode, defaults.airplaneMode),
appearanceMode: readChoice(
settings.appearanceMode,
APPEARANCE_MODE_IDS,
defaults.appearanceMode,
),
frame: readChoice(settings.frame, PHONE_FRAME_IDS, defaults.frame),
notificationSound: readChoice(
settings.notificationSound,
NOTIFICATION_SOUND_IDS,
defaults.notificationSound,
),
notificationVolume: readNumber(
settings.notificationVolume,
defaults.notificationVolume,
0,
100,
),
notifications: readNotifications(settings.notifications),
phoneScale: readNumber(
settings.phoneScale,
defaults.phoneScale,
85,
115,
),
ringtone: readChoice(
settings.ringtone,
RINGTONE_IDS,
defaults.ringtone,
),
ringtoneVolume: readNumber(
settings.ringtoneVolume,
defaults.ringtoneVolume,
0,
100,
),
streamerMode: readBoolean(settings.streamerMode, defaults.streamerMode),
wallpaper: readChoice(
settings.wallpaper,
WALLPAPER_IDS,
defaults.wallpaper,
),
},
version: 1,
}
+551 -120
View File
@@ -1,158 +1,589 @@
<script setup lang="ts">
import {
Accessibility,
BatteryMedium,
kBlockTitle,
kList,
kListItem,
kNavbar,
kNavbarBackLink,
kPage,
kRange,
kToggle,
} from 'konsta/vue'
import {
BellRing,
Bluetooth,
Check,
ChevronRight,
Clock3,
Focus,
Globe2,
Grid2X2,
KeyRound,
LockKeyhole,
EyeOff,
Monitor,
Plane,
Radio,
Search,
Settings,
Smartphone,
Sun,
UserRound,
Volume1,
Volume2,
Wifi,
} from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { PHONE_APPS } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import { WALLPAPER_IDS } from '@/utils/preferences'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
import {
APPEARANCE_MODE_IDS,
NOTIFICATION_SOUND_IDS,
PHONE_FRAME_IDS,
RINGTONE_IDS,
WALLPAPER_IDS,
type AppearanceMode,
type NotificationSoundId,
type PhoneFrameId,
type RingtoneId,
} from '@/utils/preferences'
type SettingsView =
| 'root'
| 'notifications'
| 'notification-detail'
| 'sounds'
| 'general'
| 'appearance'
| 'wallpaper'
type RootToggleKey = 'airplaneMode' | 'streamerMode'
type SubmenuView = Exclude<SettingsView, 'root' | 'notification-detail'>
const phone = usePhoneStore()
const query = ref('')
const groups = [
[
{ key: 'airplaneMode', icon: Plane, color: '#ff9500', toggle: true },
{
key: 'wifi',
icon: Wifi,
color: '#0a84ff',
value: 'SkyNet 5G',
toggle: true,
},
{
key: 'bluetooth',
icon: Bluetooth,
color: '#0a84ff',
valueKey: 'Apps.settings.on',
toggle: true,
},
{ key: 'mobileServices', icon: Radio, color: '#30d158' },
{ key: 'personalHotspot', icon: Smartphone, color: '#30d158' },
{ key: 'vpn', icon: Globe2, color: '#0a84ff' },
],
[
{ key: 'notifications', icon: BellRing, color: '#ff453a' },
{ key: 'sounds', icon: Volume2, color: '#ff2d55' },
{ key: 'focus', icon: Focus, color: '#5e5ce6' },
{ key: 'screenTime', icon: Clock3, color: '#5e5ce6' },
],
[
{ key: 'general', icon: Settings, color: '#8e8e93' },
{ key: 'display', icon: Sun, color: '#0a84ff' },
{ key: 'homeScreen', icon: Grid2X2, color: '#5e5ce6' },
{ key: 'accessibility', icon: Accessibility, color: '#0a84ff' },
{ key: 'wallpaper', icon: Monitor, color: '#64d2ff' },
{ key: 'battery', icon: BatteryMedium, color: '#30d158' },
{ key: 'privacy', icon: LockKeyhole, color: '#0a84ff' },
],
[{ key: 'passwords', icon: KeyRound, color: '#636366' }],
const activeView = ref<SettingsView>('root')
const selectedNotificationAppId = ref<PhoneAppId>('calculator')
const toggleRows = [
{ key: 'airplaneMode' as const, icon: Plane, color: '#ff9500' },
{ key: 'streamerMode' as const, icon: EyeOff, color: '#af52de' },
]
const filteredGroups = computed(() =>
groups
.map((group) =>
group.filter((row) =>
phone
.t(`Apps.settings.${row.key}`)
.toLowerCase()
.includes(query.value.toLowerCase()),
),
)
.filter((group) => group.length),
const serviceRows = [
{
key: 'notifications',
view: 'notifications' as const,
icon: BellRing,
color: '#ff453a',
},
{
key: 'sounds',
view: 'sounds' as const,
icon: Volume2,
color: '#ff2d55',
},
]
const preferenceRows = [
{
key: 'general',
view: 'general' as const,
icon: Settings,
color: '#8e8e93',
},
{
key: 'appearance',
view: 'appearance' as const,
icon: Sun,
color: '#0a84ff',
},
{
key: 'wallpaper',
view: 'wallpaper' as const,
icon: Monitor,
color: '#64d2ff',
},
]
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
const visibleToggleRows = computed(() =>
toggleRows.filter((row) => matchesSearch(row.key)),
)
const visibleServiceRows = computed(() =>
serviceRows.filter((row) => matchesSearch(row.key)),
)
const visiblePreferenceRows = computed(() =>
preferenceRows.filter((row) => matchesSearch(row.key)),
)
const notificationApps = computed(() =>
[...PHONE_APPS].sort((left, right) => left.gridOrder - right.gridOrder),
)
const selectedNotificationApp = computed(
() =>
PHONE_APPS.find((app) => app.id === selectedNotificationAppId.value) ??
PHONE_APPS[0],
)
const activeTitle = computed(() => {
if (activeView.value === 'notification-detail') {
return selectedNotificationApp.value
? phone.t(selectedNotificationApp.value.labelKey)
: phone.t('Apps.settings.notifications')
}
return phone.t(`Apps.settings.${activeView.value}`)
})
function matchesSearch(key: string): boolean {
return (
!normalizedQuery.value ||
phone
.t(`Apps.settings.${key}`)
.toLowerCase()
.includes(normalizedQuery.value)
)
}
function openView(view: SubmenuView): void {
activeView.value = view
}
function openNotificationApp(app: PhoneAppDefinition): void {
selectedNotificationAppId.value = app.id
activeView.value = 'notification-detail'
}
function goBack(): void {
activeView.value =
activeView.value === 'notification-detail' ? 'notifications' : 'root'
}
function toggleRootSetting(key: RootToggleKey): void {
phone.setPreference(key, !phone.preferences.settings[key])
}
function updateVolume(
key: 'notificationVolume' | 'phoneScale' | 'ringtoneVolume',
event: Event,
): void {
phone.setPreference(
key,
Number.parseInt((event.target as HTMLInputElement).value, 10),
)
}
function selectAppearanceMode(mode: AppearanceMode): void {
phone.setPreference('appearanceMode', mode)
}
function selectFrame(frame: PhoneFrameId): void {
phone.setPreference('frame', frame)
}
function selectRingtone(ringtone: RingtoneId): void {
phone.setPreference('ringtone', ringtone)
}
function selectNotificationSound(sound: NotificationSoundId): void {
phone.setPreference('notificationSound', sound)
}
</script>
<template>
<main class="native-app reference-settings">
<section class="settings-scroll">
<k-page class="native-app konsta-settings">
<section v-if="activeView === 'root'" class="settings-scroll">
<h1>{{ phone.t('Apps.settings.name') }}</h1>
<div class="settings-search">
<Search :size="17" /><input
<Search :size="17" />
<input
v-model="query"
:placeholder="phone.t('Apps.settings.searchPlaceholder')"
type="search"
/>
</div>
<article class="settings-account">
<span><UserRound :size="34" /></span>
<div>
<strong>{{ phone.t('Apps.settings.accountName') }}</strong
><small>{{ phone.t('Apps.settings.accountDetail') }}</small>
<strong>{{ phone.t('Apps.settings.accountName') }}</strong>
<small>{{ phone.t('Apps.settings.accountDetail') }}</small>
</div>
<ChevronRight :size="19" />
</article>
<section
v-for="(group, groupIndex) in filteredGroups"
:key="groupIndex"
class="reference-settings-group"
<k-list
v-if="visibleToggleRows.length"
strong
inset
class="settings-konsta-list"
>
<article v-for="row in group" :key="row.key">
<span class="settings-symbol" :style="{ background: row.color }"
><component :is="row.icon" :size="16"
/></span>
<div>
<strong>{{ phone.t(`Apps.settings.${row.key}`) }}</strong
><small v-if="row.value">{{ row.value }}</small
><small v-else-if="row.valueKey">{{ phone.t(row.valueKey) }}</small>
</div>
<button
v-if="row.toggle"
class="ios-switch"
:class="{
active:
phone.preferences.settings[
row.key as 'airplaneMode' | 'wifi' | 'bluetooth'
],
}"
type="button"
:aria-label="phone.t(`Apps.settings.toggle.${row.key}`)"
@click="
phone.setSetting(
row.key as 'airplaneMode' | 'wifi' | 'bluetooth',
!phone.preferences.settings[
row.key as 'airplaneMode' | 'wifi' | 'bluetooth'
],
)
"
>
<span /></button
><ChevronRight v-else :size="18" class="settings-chevron" />
</article>
</section>
<h2 class="settings-section-title">
{{ phone.t('Apps.settings.wallpaperPicker') }}
</h2>
<section class="wallpaper-picker">
<button
v-for="wallpaper in WALLPAPER_IDS"
:key="wallpaper"
type="button"
:class="[
`wallpaper--${wallpaper}`,
{ active: phone.preferences.settings.wallpaper === wallpaper },
]"
:aria-label="phone.t(`Apps.settings.wallpapers.${wallpaper}`)"
@click="phone.setWallpaper(wallpaper)"
/>
</section>
<k-list-item
v-for="row in visibleToggleRows"
:key="row.key"
:title="phone.t(`Apps.settings.${row.key}`)"
>
<template #media>
<span class="settings-symbol" :style="{ background: row.color }">
<component :is="row.icon" :size="16" />
</span>
</template>
<template #after>
<k-toggle
component="div"
:checked="phone.preferences.settings[row.key]"
:aria-label="phone.t(`Apps.settings.toggle.${row.key}`)"
@change="toggleRootSetting(row.key)"
/>
</template>
</k-list-item>
</k-list>
<k-list
v-if="visibleServiceRows.length"
strong
inset
class="settings-konsta-list"
>
<k-list-item
v-for="row in visibleServiceRows"
:key="row.key"
link
link-component="button"
:title="phone.t(`Apps.settings.${row.key}`)"
@click="openView(row.view)"
>
<template #media>
<span class="settings-symbol" :style="{ background: row.color }">
<component :is="row.icon" :size="16" />
</span>
</template>
</k-list-item>
</k-list>
<k-list
v-if="visiblePreferenceRows.length"
strong
inset
class="settings-konsta-list"
>
<k-list-item
v-for="row in visiblePreferenceRows"
:key="row.key"
link
link-component="button"
:title="phone.t(`Apps.settings.${row.key}`)"
@click="openView(row.view)"
>
<template #media>
<span class="settings-symbol" :style="{ background: row.color }">
<component :is="row.icon" :size="16" />
</span>
</template>
</k-list-item>
</k-list>
</section>
</main>
<template v-else>
<k-navbar :title="activeTitle" class="settings-konsta-navbar">
<template #left>
<k-navbar-back-link
component="button"
:show-text="false"
:text="phone.t('Apps.settings.back')"
:aria-label="phone.t('Apps.settings.back')"
@click="goBack"
/>
</template>
</k-navbar>
<section class="settings-subpage-scroll">
<template v-if="activeView === 'notifications'">
<k-list strong inset class="settings-konsta-list">
<k-list-item
v-for="app in notificationApps"
:key="app.id"
link
link-component="button"
:title="phone.t(app.labelKey)"
:after="
phone.t(
phone.preferences.settings.notifications[app.id].enabled
? 'Apps.settings.on'
: 'Apps.settings.off',
)
"
@click="openNotificationApp(app)"
>
<template #media>
<img
class="settings-app-icon"
:src="app.iconImage"
alt=""
draggable="false"
/>
</template>
</k-list-item>
</k-list>
</template>
<template
v-else-if="
activeView === 'notification-detail' && selectedNotificationApp
"
>
<div class="settings-notification-app">
<img
:src="selectedNotificationApp.iconImage"
alt=""
draggable="false"
/>
<strong>{{ phone.t(selectedNotificationApp.labelKey) }}</strong>
</div>
<k-list strong inset class="settings-konsta-list">
<k-list-item :title="phone.t('Apps.settings.allowNotifications')">
<template #after>
<k-toggle
component="div"
:checked="
phone.preferences.settings.notifications[
selectedNotificationApp.id
].enabled
"
:aria-label="
phone.t('Apps.settings.toggle.notifications', {
app: phone.t(selectedNotificationApp.labelKey),
})
"
@change="
phone.setAppNotification(
selectedNotificationApp.id,
'enabled',
!phone.preferences.settings.notifications[
selectedNotificationApp.id
].enabled,
)
"
/>
</template>
</k-list-item>
<k-list-item :title="phone.t('Apps.settings.notificationSounds')">
<template #after>
<k-toggle
component="div"
:disabled="
!phone.preferences.settings.notifications[
selectedNotificationApp.id
].enabled
"
:checked="
phone.preferences.settings.notifications[
selectedNotificationApp.id
].sounds
"
:aria-label="
phone.t('Apps.settings.toggle.notificationSounds', {
app: phone.t(selectedNotificationApp.labelKey),
})
"
@change="
phone.setAppNotification(
selectedNotificationApp.id,
'sounds',
!phone.preferences.settings.notifications[
selectedNotificationApp.id
].sounds,
)
"
/>
</template>
</k-list-item>
</k-list>
</template>
<template v-else-if="activeView === 'sounds'">
<k-block-title>
{{ phone.t('Apps.settings.ringtoneVolume') }}
</k-block-title>
<k-list strong inset class="settings-konsta-list settings-range-list">
<k-list-item>
<template #inner>
<div class="settings-range-row">
<Volume1 :size="17" />
<k-range
:value="phone.preferences.settings.ringtoneVolume"
:min="0"
:max="100"
@input="updateVolume('ringtoneVolume', $event)"
/>
<Volume2 :size="19" />
</div>
</template>
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.notificationVolume') }}
</k-block-title>
<k-list strong inset class="settings-konsta-list settings-range-list">
<k-list-item>
<template #inner>
<div class="settings-range-row">
<Volume1 :size="17" />
<k-range
:value="phone.preferences.settings.notificationVolume"
:min="0"
:max="100"
@input="updateVolume('notificationVolume', $event)"
/>
<Volume2 :size="19" />
</div>
</template>
</k-list-item>
</k-list>
<k-block-title>{{ phone.t('Apps.settings.ringtone') }}</k-block-title>
<k-list strong inset class="settings-konsta-list">
<k-list-item
v-for="ringtone in RINGTONE_IDS"
:key="ringtone"
link
link-component="button"
:chevron="false"
:title="phone.t(`Apps.settings.ringtones.${ringtone}`)"
@click="selectRingtone(ringtone)"
>
<template #after>
<Check
v-if="phone.preferences.settings.ringtone === ringtone"
:size="19"
class="settings-check"
/>
</template>
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.notificationSound') }}
</k-block-title>
<k-list strong inset class="settings-konsta-list">
<k-list-item
v-for="sound in NOTIFICATION_SOUND_IDS"
:key="sound"
link
link-component="button"
:chevron="false"
:title="phone.t(`Apps.settings.notificationSoundsList.${sound}`)"
@click="selectNotificationSound(sound)"
>
<template #after>
<Check
v-if="phone.preferences.settings.notificationSound === sound"
:size="19"
class="settings-check"
/>
</template>
</k-list-item>
</k-list>
</template>
<template v-else-if="activeView === 'general'">
<k-block-title>{{ phone.t('Apps.settings.about') }}</k-block-title>
<k-list strong inset class="settings-konsta-list">
<k-list-item
:title="phone.t('Apps.settings.deviceName')"
:after="phone.t('Apps.settings.deviceNameValue')"
/>
<k-list-item
:title="phone.t('Apps.settings.softwareVersion')"
after="0.1.0"
/>
<k-list-item
:title="phone.t('Apps.settings.language')"
:after="phone.t('Apps.settings.languageValue')"
/>
<k-list-item
:title="phone.t('Apps.settings.localStorage')"
:after="phone.t('Apps.settings.localStorageValue')"
/>
</k-list>
</template>
<template v-else-if="activeView === 'appearance'">
<k-block-title>
{{ phone.t('Apps.settings.appearanceMode') }}
</k-block-title>
<k-list strong inset class="settings-konsta-list">
<k-list-item
v-for="mode in APPEARANCE_MODE_IDS"
:key="mode"
link
link-component="button"
:chevron="false"
:title="phone.t(`Apps.settings.${mode}`)"
@click="selectAppearanceMode(mode)"
>
<template #after>
<Check
v-if="phone.preferences.settings.appearanceMode === mode"
:size="19"
class="settings-check"
/>
</template>
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.phoneScale') }} ·
{{ phone.preferences.settings.phoneScale }}%
</k-block-title>
<k-list strong inset class="settings-konsta-list settings-range-list">
<k-list-item>
<template #inner>
<div class="settings-scale-row">
<span>A</span>
<k-range
:value="phone.preferences.settings.phoneScale"
:min="85"
:max="115"
:step="5"
@input="updateVolume('phoneScale', $event)"
/>
<strong>A</strong>
</div>
</template>
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.phoneFrame') }}
</k-block-title>
<div class="settings-frame-picker">
<button
v-for="frame in PHONE_FRAME_IDS"
:key="frame"
type="button"
:class="{
active: phone.preferences.settings.frame === frame,
}"
:aria-label="phone.t(`Apps.settings.frames.${frame}`)"
@click="selectFrame(frame)"
>
<img :src="PHONE_FRAME_IMAGES[frame]" alt="" draggable="false" />
<span>{{ phone.t(`Apps.settings.frames.${frame}`) }}</span>
</button>
</div>
</template>
<template v-else-if="activeView === 'wallpaper'">
<k-block-title>
{{ phone.t('Apps.settings.wallpaperPicker') }}
</k-block-title>
<section class="wallpaper-picker settings-wallpaper-picker">
<button
v-for="wallpaper in WALLPAPER_IDS"
:key="wallpaper"
type="button"
:class="[
`wallpaper--${wallpaper}`,
{
active: phone.preferences.settings.wallpaper === wallpaper,
},
]"
:aria-label="phone.t(`Apps.settings.wallpapers.${wallpaper}`)"
@click="phone.setWallpaper(wallpaper)"
/>
</section>
</template>
</section>
</template>
</k-page>
</template>
+17 -7
View File
@@ -54,13 +54,23 @@ Locales["en"] = {
},
},
settings = {
name = "Settings", searchPlaceholder = "Search", wifi = "Wi-Fi", bluetooth = "Bluetooth",
airplaneMode = "Airplane Mode", wallpaper = "Wallpaper", on = "On", accountName = "Sky Citizen",
accountDetail = "Apple ID, iCloud, Media & Purchases", mobileServices = "Mobile Services", personalHotspot = "Personal Hotspot",
vpn = "VPN", notifications = "Notifications", sounds = "Sounds & Haptics", focus = "Focus", screenTime = "Screen Time",
general = "General", display = "Display & Brightness", homeScreen = "Home Screen & App Library", accessibility = "Accessibility",
battery = "Battery", privacy = "Privacy & Security", passwords = "Passwords", wallpaperPicker = "Built-in Wallpapers",
toggle = { wifi = "Toggle Wi-Fi", bluetooth = "Toggle Bluetooth", airplaneMode = "Toggle Airplane Mode" },
name = "Settings", searchPlaceholder = "Search", airplaneMode = "Airplane Mode", streamerMode = "Streamer Mode",
wallpaper = "Wallpaper", on = "On", off = "Off", accountName = "Sky Citizen",
accountDetail = "Apple ID, iCloud, Media & Purchases", notifications = "Notifications", sounds = "Sounds & Haptics",
general = "General Settings", appearance = "Appearance", allowNotifications = "Allow Notifications",
notificationSounds = "Sounds", ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume",
ringtone = "Ringtone", notificationSound = "Notification Sound", appearanceMode = "Appearance Mode",
automatic = "Automatic", light = "Light", dark = "Dark", phoneScale = "Phone Scale", phoneFrame = "Phone Frame",
about = "About", deviceName = "Device Name", deviceNameValue = "Sky Phone", softwareVersion = "Software Version",
language = "Language", languageValue = "English", localStorage = "Local Storage", localStorageValue = "On Device",
back = "Settings", wallpaperPicker = "Built-in Wallpapers",
toggle = {
airplaneMode = "Toggle Airplane Mode", streamerMode = "Toggle Streamer Mode",
notifications = "Toggle notifications for {app}", notificationSounds = "Toggle notification sounds for {app}",
},
frames = { black = "Black", blue = "Blue", green = "Green", lavender = "Lavender", white = "White" },
ringtones = { skyline = "Skyline", horizon = "Horizon", pulse = "Pulse" },
notificationSoundsList = { chime = "Chime", signal = "Signal", soft = "Soft" },
wallpapers = { midnight = "Midnight wallpaper", aurora = "Aurora wallpaper", ember = "Ember wallpaper" },
},
},
+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-CWWwvo5a.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-RGdqVA4s.css">
<script type="module" crossorigin src="./assets/sky-index-CRNziHJr.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DINlYwL-.css">
</head>
<body>
<div id="app"></div>