mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ADD - introduce advanced phone setup and reset flow
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite --port 5174 --strictPort\" \"node testserver/index.cjs 3002\"",
|
||||
"dev:dev-branch": "concurrently -k -n DEV-UI,DEV-DATA -c blue,magenta \"vite --port 5175 --strictPort\" \"node testserver/index.cjs 3003\"",
|
||||
"build": "pnpm typecheck && pnpm build-only",
|
||||
"build-only": "vite build && node build.cjs",
|
||||
"preview": "vite preview",
|
||||
|
||||
+35
-8
@@ -16,6 +16,7 @@ import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
|
||||
import PhoneMemoRecorder from '@/components/PhoneMemoRecorder.vue'
|
||||
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
|
||||
import PhonePasscode from '@/components/PhonePasscode.vue'
|
||||
import PhoneSetupAssistant from '@/components/PhoneSetupAssistant.vue'
|
||||
import PhoneNotifications from '@/components/PhoneNotifications.vue'
|
||||
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
|
||||
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
|
||||
@@ -293,11 +294,19 @@ const passcodeError = ref('')
|
||||
const passcodeResetKey = ref(0)
|
||||
const passcodeRetrySeconds = ref(0)
|
||||
const passcodeVisible = ref(false)
|
||||
const setupPreviewDismissed = ref(false)
|
||||
const pendingUnlockRoute = ref<string | null>(null)
|
||||
const unlockedServicesLoaded = ref(false)
|
||||
const controlCenterOpened = ref(false)
|
||||
const activitySuspended = ref(false)
|
||||
const simPicker = ref<SimPickerPayload | null>(null)
|
||||
const setupRequired = computed(
|
||||
() =>
|
||||
!phone.preferences.settings.setupCompleted ||
|
||||
(isDevelopment &&
|
||||
developmentParameters.has('setupPreview') &&
|
||||
!setupPreviewDismissed.value),
|
||||
)
|
||||
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const viewportScale = ref(getViewportScale())
|
||||
const phoneBaseZoom = computed(
|
||||
@@ -443,6 +452,16 @@ function loadUnlockedPhoneData(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function completePhoneSetup(): void {
|
||||
setupPreviewDismissed.value = true
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
passcodeVisible.value = false
|
||||
controlCenterOpened.value = false
|
||||
void router.replace('/')
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
|
||||
async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
const response = await nuiCall<PhoneOpenPayload>('development:bootstrap')
|
||||
if (response.success && response.data) {
|
||||
@@ -1253,7 +1272,9 @@ watch(
|
||||
}
|
||||
return
|
||||
}
|
||||
isLocked.value = !isDevelopment || developmentLockScreenPreview
|
||||
isLocked.value = setupRequired.value
|
||||
? false
|
||||
: !isDevelopment || developmentLockScreenPreview
|
||||
unlockedServicesLoaded.value = false
|
||||
controlCenterOpened.value = false
|
||||
weather.start()
|
||||
@@ -1270,7 +1291,7 @@ watch(
|
||||
startPasscodeLock(passcodeRetrySeconds.value)
|
||||
}
|
||||
phone.setLaunchOrigin(null)
|
||||
if (isLocked.value) void router.replace('/')
|
||||
if (isLocked.value || setupRequired.value) void router.replace('/')
|
||||
else loadUnlockedPhoneData()
|
||||
},
|
||||
)
|
||||
@@ -1379,31 +1400,33 @@ onBeforeUnmount(() => {
|
||||
v-if="!isLocked"
|
||||
:active-call-return="showActiveCallReturn"
|
||||
:control-center-opened="controlCenterOpened"
|
||||
lockable
|
||||
:interactive="!setupRequired"
|
||||
:lockable="!setupRequired"
|
||||
@active-call="returnToActiveCall"
|
||||
@control-center="toggleControlCenter"
|
||||
@lock="lockPhone"
|
||||
/>
|
||||
<SpringboardView v-if="!isDevelopmentRoute" />
|
||||
<SpringboardView v-if="!isDevelopmentRoute && !setupRequired" />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition :name="appTransitionName">
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="isAppRoute || isDevelopmentRoute"
|
||||
v-if="!setupRequired && (isAppRoute || isDevelopmentRoute)"
|
||||
:key="
|
||||
isDevelopmentRoute ? String(route.name) : route.path
|
||||
"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<PhoneHomeIndicator v-if="!isLocked" />
|
||||
<PhoneHomeIndicator v-if="!isLocked && !setupRequired" />
|
||||
<PhoneControlCenter
|
||||
v-if="!setupRequired"
|
||||
:opened="controlCenterOpened"
|
||||
@close="controlCenterOpened = false"
|
||||
/>
|
||||
<Transition name="lock-screen">
|
||||
<PhoneLockScreen
|
||||
v-if="isLocked"
|
||||
v-if="isLocked && !setupRequired"
|
||||
:notifications="notifications.lockScreenNotifications"
|
||||
@camera="unlockCamera"
|
||||
@clear-notifications="notifications.clearLockScreen"
|
||||
@@ -1414,7 +1437,7 @@ onBeforeUnmount(() => {
|
||||
</Transition>
|
||||
<Transition name="lock-screen">
|
||||
<PhonePasscode
|
||||
v-if="isLocked && passcodeVisible"
|
||||
v-if="isLocked && passcodeVisible && !setupRequired"
|
||||
:busy="passcodeBusy"
|
||||
:disabled="passcodeRetrySeconds > 0"
|
||||
:error="passcodeError"
|
||||
@@ -1432,6 +1455,10 @@ onBeforeUnmount(() => {
|
||||
@complete="submitUnlockPasscode"
|
||||
/>
|
||||
</Transition>
|
||||
<PhoneSetupAssistant
|
||||
v-if="setupRequired"
|
||||
@complete="completePhoneSetup"
|
||||
/>
|
||||
<PhoneNotifications
|
||||
:notification="notifications.current"
|
||||
@close="notifications.dismissCurrent()"
|
||||
|
||||
@@ -10,12 +10,15 @@ describe('browser development preview contract', () => {
|
||||
"developmentParameters.has('lockScreenPreview')",
|
||||
)
|
||||
expect(source).toContain(
|
||||
'isLocked.value = !isDevelopment || developmentLockScreenPreview',
|
||||
': !isDevelopment || developmentLockScreenPreview',
|
||||
)
|
||||
expect(source).toContain("developmentParameters.has('setupPreview')")
|
||||
})
|
||||
|
||||
it('loads authenticated app data without replacing direct app routes', () => {
|
||||
expect(source).toContain("if (isLocked.value) void router.replace('/')")
|
||||
expect(source).toContain(
|
||||
"if (isLocked.value || setupRequired.value) void router.replace('/')",
|
||||
)
|
||||
expect(source).toContain('else loadUnlockedPhoneData()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./PhoneSetupAssistant.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('PhoneSetupAssistant contract', () => {
|
||||
it('uses first-party controls and exposes the complete setup journey', () => {
|
||||
expect(source).not.toContain("from 'konsta/vue'")
|
||||
expect(source).toContain('PhonePasscode')
|
||||
expect(source).toContain("step === 1")
|
||||
expect(source).toContain("step === 8")
|
||||
expect(source).toContain("['performance', 'ultimate']")
|
||||
expect(source).toContain('WALLPAPER_IDS')
|
||||
expect(source).toContain('setAllAppNotifications')
|
||||
expect(source).toContain('appStore.claimApp')
|
||||
expect(source).toContain('phone.completeSetup()')
|
||||
})
|
||||
|
||||
it('persists progress and supports resuming or moving backward', () => {
|
||||
expect(source).toContain('phone.preferences.settings.setupStep')
|
||||
expect(source).toContain('phone.setSetupStep(step.value)')
|
||||
expect(source).toContain('@click="moveTo(step - 1)"')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,13 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
activeCallReturn?: boolean
|
||||
controlCenterOpened?: boolean
|
||||
interactive?: boolean
|
||||
lockable?: boolean
|
||||
}>(),
|
||||
{
|
||||
activeCallReturn: false,
|
||||
controlCenterOpened: false,
|
||||
interactive: true,
|
||||
lockable: false,
|
||||
},
|
||||
)
|
||||
@@ -30,6 +32,7 @@ function updateTime(): void {
|
||||
}
|
||||
|
||||
function handleTimeClick(): void {
|
||||
if (!props.interactive) return
|
||||
if (props.activeCallReturn) {
|
||||
emit('activeCall')
|
||||
return
|
||||
@@ -76,7 +79,8 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
:aria-label="phone.t('ControlCenter.open')"
|
||||
:aria-expanded="controlCenterOpened"
|
||||
@click.stop="emit('controlCenter')"
|
||||
:disabled="!interactive"
|
||||
@click.stop="interactive && emit('controlCenter')"
|
||||
>
|
||||
<PhoneStatusIndicators
|
||||
:airplane-mode="phone.preferences.settings.airplaneMode"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
clampPhoneScale,
|
||||
ensureAppNotificationPreferences,
|
||||
parsePhonePreferences,
|
||||
@@ -3900,11 +3901,38 @@ const defaultLocales: LocaleTree = {
|
||||
removeDeviceBody:
|
||||
'Enter your Sky Cloud password to remove this device from the account.',
|
||||
signOut: 'Sign Out',
|
||||
reset: 'Transfer or Reset Phone',
|
||||
transferOrReset: 'Transfer or Reset Phone',
|
||||
transferOrResetDescription: 'Prepare this phone for a fresh setup',
|
||||
resetHeroTitle: 'A clean start, without losing what follows you',
|
||||
resetHeroBody:
|
||||
'Erase local content and settings from this phone. Information attached to your SIM or stored in supported accounts remains available.',
|
||||
erasedFromPhone: 'Erased From This Phone',
|
||||
eraseDeviceSettings: 'Passcode, preferences and Home Screen layout',
|
||||
eraseLocalContent: 'Photos, notes and content stored only locally',
|
||||
eraseLocalApps: 'Downloaded apps and local app sessions',
|
||||
keptSafe: 'Kept Safe',
|
||||
keepSimData: 'SIM Card & Phone Number',
|
||||
keepSimDataBody: 'Your number and SIM-backed communication remain on the SIM.',
|
||||
keepCloudData: 'Account & Cloud Data',
|
||||
keepCloudDataBody: 'Supported information remains in its app or Sky Cloud account.',
|
||||
resetSetupAssistant: 'When erasing is complete, Setup Assistant starts automatically.',
|
||||
factoryReset: 'Erase All Content and Settings',
|
||||
factoryResetBody:
|
||||
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
|
||||
factoryResetProgress: 'Erasing iFruit Phone',
|
||||
factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
|
||||
'This cannot be undone. Local settings, apps and unsynced content are erased. Your SIM number and account-backed data remain available.',
|
||||
factoryResetProgress: 'Erasing Sky Phone',
|
||||
factoryResetWarning: 'Keep this phone open. Your SIM and cloud data are safe.',
|
||||
factoryResetSystemProcess: 'Secure system process',
|
||||
factoryResetPreparing: 'Preparing secure reset',
|
||||
factoryResetPreparingDetail: 'Checking device data',
|
||||
factoryResetRemoving: 'Removing personal data',
|
||||
factoryResetRemovingDetail: 'Clearing apps and local content',
|
||||
factoryResetSecuring: 'Protecting linked services',
|
||||
factoryResetSecuringDetail: 'Preserving SIM and cloud data',
|
||||
factoryResetFinishing: 'Finishing setup',
|
||||
factoryResetFinishingDetail: 'Preparing the welcome screen',
|
||||
factoryResetSimSafe: 'SIM protected',
|
||||
factoryResetCloudSafe: 'Cloud protected',
|
||||
passcode: {
|
||||
description:
|
||||
'A passcode protects the contents of this phone. It stays with the device when the SIM or Sky Cloud account changes.',
|
||||
@@ -4023,11 +4051,141 @@ const defaultLocales: LocaleTree = {
|
||||
volume: 'Volume',
|
||||
wifi: 'Wi-Fi',
|
||||
},
|
||||
Setup: {
|
||||
title: 'Sky Phone Setup Assistant',
|
||||
ownerFallback: 'Sky Phone Owner',
|
||||
getStarted: 'Get Started',
|
||||
setUpLater: 'Set Up Later',
|
||||
welcome: {
|
||||
title: 'Welcome to Sky Phone',
|
||||
eyebrow: 'Designed around you',
|
||||
hello: 'hello',
|
||||
hallo: 'hallo',
|
||||
bonjour: 'bonjour',
|
||||
body: 'Hello, {name}. Let’s make this phone unmistakably yours.',
|
||||
private: 'Private by design',
|
||||
personal: 'Made for you',
|
||||
},
|
||||
connection: {
|
||||
eyebrow: 'Mobile Connection',
|
||||
title: 'Your connection is ready',
|
||||
body: 'Sky Phone automatically detects the SIM installed in this device.',
|
||||
noSim: 'No SIM installed',
|
||||
ready: 'Connected to the Sky network',
|
||||
offline: 'Phone works offline; calls and messages require a SIM',
|
||||
preserved:
|
||||
'Your SIM number and SIM-backed conversations remain with the SIM, even if this phone is erased.',
|
||||
},
|
||||
cloud: {
|
||||
eyebrow: 'Sky Cloud',
|
||||
title: 'Keep important data with you',
|
||||
body: 'Sign in to sync supported app data and recover it on another Sky Phone.',
|
||||
signIn: 'Sign In',
|
||||
create: 'Create Account',
|
||||
signInTitle: 'Sign in to Sky Cloud',
|
||||
createTitle: 'Create your Sky Cloud account',
|
||||
signInBody: 'Access your protected data, purchases and settings on this phone.',
|
||||
createBody: 'Choose your personal iFruit address and keep important data available across devices.',
|
||||
accountPreview: 'Your Sky Cloud ID',
|
||||
accountName: 'Account name',
|
||||
addressPlaceholder: 'alex.morgan',
|
||||
email: 'Sky Cloud Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
strength0: 'At least 6 characters',
|
||||
strength1: 'Basic password',
|
||||
strength2: 'Good password',
|
||||
strength3: 'Strong password',
|
||||
strength4: 'Excellent password',
|
||||
signInAction: 'Sign In Securely',
|
||||
createAction: 'Create Sky Cloud Account',
|
||||
securityNote: 'Encrypted connection · Your password stays private',
|
||||
connected: 'Sky Cloud connected',
|
||||
invalid: 'Enter a valid email and a password with at least 6 characters.',
|
||||
errors: {
|
||||
invalid_email: 'Enter a valid Sky Cloud email.',
|
||||
invalid_password: 'Your password must contain at least 6 characters.',
|
||||
invalid_credentials: 'Email or password is incorrect.',
|
||||
email_taken: 'This Sky Cloud email is already registered.',
|
||||
rate_limited: 'Too many attempts. Try again shortly.',
|
||||
request_failed: 'Sky Cloud is temporarily unavailable.',
|
||||
},
|
||||
},
|
||||
security: {
|
||||
eyebrow: 'Privacy & Security',
|
||||
title: 'Protect this phone',
|
||||
body: 'Choose a four- or six-digit passcode to keep your apps and local information private.',
|
||||
local: 'The passcode belongs to this physical phone and is never transferred with the SIM.',
|
||||
create: 'Create Passcode',
|
||||
createSelected: 'Create {count}-Digit Passcode',
|
||||
lengthTitle: 'Choose passcode length',
|
||||
fourDigit: '4-Digit Code',
|
||||
sixDigit: '6-Digit Code',
|
||||
enter: 'Create a Passcode',
|
||||
confirm: 'Verify Your Passcode',
|
||||
codeHint: 'Enter a memorable six-digit code.',
|
||||
fourDigitHint: 'Enter a memorable four-digit code.',
|
||||
sixDigitHint: 'Enter a memorable six-digit code.',
|
||||
mismatch: 'The passcodes did not match. Try again.',
|
||||
failed: 'The passcode could not be saved.',
|
||||
},
|
||||
appearance: {
|
||||
eyebrow: 'Display',
|
||||
title: 'Choose your appearance',
|
||||
body: 'Automatic follows your system. You can change this at any time in Settings.',
|
||||
},
|
||||
performance: {
|
||||
eyebrow: 'Performance',
|
||||
title: 'Choose how your phone feels',
|
||||
body: 'Balance responsiveness and visual effects for your FiveM setup.',
|
||||
performance: 'Fastest response with optimized, blur-free glass.',
|
||||
ultimate: 'Rich depth, live blur and the complete glass experience.',
|
||||
changeLater: 'You can switch modes later under Settings › Appearance.',
|
||||
},
|
||||
wallpaper: {
|
||||
eyebrow: 'Personalize',
|
||||
title: 'Make it yours',
|
||||
body: 'Choose one of twelve crafted Sky Phone backgrounds.',
|
||||
},
|
||||
notifications: {
|
||||
eyebrow: 'Stay Informed',
|
||||
title: 'Notifications your way',
|
||||
body: 'Choose how apps may reach you. Critical alarms and calls stay available.',
|
||||
allow: 'Allow App Notifications',
|
||||
allowBody: 'Show banners, lock-screen updates and badges.',
|
||||
sounds: 'Notification Sounds',
|
||||
soundsBody: 'Play a subtle sound for incoming updates.',
|
||||
},
|
||||
apps: {
|
||||
eyebrow: 'Suggested for You',
|
||||
title: 'Start with your favorites',
|
||||
body: 'Choose optional apps. Every essential phone app is already included.',
|
||||
install: 'Add {count} Apps',
|
||||
descriptions: {
|
||||
banking: 'Secure city banking',
|
||||
garage: 'Your vehicles at a glance',
|
||||
skyride: 'Request a ride',
|
||||
citymarkt: 'Shop local listings',
|
||||
picstagram: 'Share photos',
|
||||
snake: 'A timeless game',
|
||||
},
|
||||
},
|
||||
ready: {
|
||||
eyebrow: 'Setup Complete',
|
||||
title: 'Welcome, {name}',
|
||||
body: 'Your Sky Phone is configured and ready. Your choices can always be refined in Settings.',
|
||||
localOnly: 'Stored on this phone',
|
||||
enter: 'Enter Sky Phone',
|
||||
review: 'Review Setup',
|
||||
},
|
||||
},
|
||||
Common: {
|
||||
add: 'Add',
|
||||
cancel: 'Cancel',
|
||||
clear: 'Clear',
|
||||
close: 'Close',
|
||||
continue: 'Continue',
|
||||
back: 'Back',
|
||||
delete: 'Delete',
|
||||
done: 'Done',
|
||||
@@ -4373,6 +4531,34 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.preferences.settings.ringtoneVolume = volume
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
setAllAppNotifications(enabled: boolean, sounds: boolean): void {
|
||||
for (const preferences of Object.values(
|
||||
this.preferences.settings.notifications,
|
||||
)) {
|
||||
preferences.enabled = enabled
|
||||
preferences.sounds = enabled && sounds
|
||||
}
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
setSetupStep(step: number): void {
|
||||
this.preferences.settings.setupStep = Math.min(
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
Math.max(0, Math.floor(step)),
|
||||
)
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
completeSetup(): void {
|
||||
this.preferences.settings.setupCompleted = true
|
||||
this.preferences.settings.setupStep = PHONE_SETUP_LAST_STEP
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
resetAfterFactoryReset(): void {
|
||||
this.persistenceGeneration += 1
|
||||
this.preferences = cloneJsonData(DEFAULT_PHONE_PREFERENCES)
|
||||
this.deviceRevisions = {}
|
||||
if (this.device) this.device.data = {}
|
||||
this.security = { enabled: false, length: null, lockedUntil: 0 }
|
||||
},
|
||||
setSystemDarkMode(value: boolean): void {
|
||||
this.systemDarkMode = value
|
||||
},
|
||||
|
||||
@@ -2,10 +2,39 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
parsePhonePreferences,
|
||||
WALLPAPER_IDS,
|
||||
} from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('starts Setup Assistant for a phone without saved settings', () => {
|
||||
const value = parsePhonePreferences(null)
|
||||
|
||||
expect(value.settings.setupCompleted).toBe(false)
|
||||
expect(value.settings.setupStep).toBe(0)
|
||||
})
|
||||
|
||||
it('migrates existing phones past Setup Assistant', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({ version: 1, settings: { wallpaper: 'aurora' } }),
|
||||
)
|
||||
|
||||
expect(value.settings.setupCompleted).toBe(true)
|
||||
expect(value.settings.setupStep).toBe(PHONE_SETUP_LAST_STEP)
|
||||
})
|
||||
|
||||
it('restores an interrupted Setup Assistant step', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
settings: { setupCompleted: false, setupStep: 5 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(value.settings.setupCompleted).toBe(false)
|
||||
expect(value.settings.setupStep).toBe(5)
|
||||
})
|
||||
|
||||
it('falls back for malformed and obsolete records', () => {
|
||||
expect(parsePhonePreferences('{')).toEqual(DEFAULT_PHONE_PREFERENCES)
|
||||
expect(parsePhonePreferences('{"version":2}')).toEqual(
|
||||
|
||||
@@ -39,6 +39,7 @@ export const WALLPAPER_IDS = [
|
||||
export const PHONE_SCALE_MIN = 75
|
||||
export const PHONE_SCALE_MAX = 150
|
||||
export const PHONE_SCALE_STEP = 5
|
||||
export const PHONE_SETUP_LAST_STEP = 9
|
||||
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
export type GraphicsMode = (typeof GRAPHICS_MODE_IDS)[number]
|
||||
@@ -79,6 +80,8 @@ export type PhonePreferencesV1 = {
|
||||
ringtone: RingtoneId
|
||||
ringtoneVolume: number
|
||||
screenBrightness: number
|
||||
setupCompleted: boolean
|
||||
setupStep: number
|
||||
streamerMode: boolean
|
||||
wallpaper: WallpaperId
|
||||
wallpaperHistory: WallpaperHistoryEntry[]
|
||||
@@ -149,6 +152,8 @@ export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
|
||||
ringtone: 'skyline',
|
||||
ringtoneVolume: 80,
|
||||
screenBrightness: 100,
|
||||
setupCompleted: false,
|
||||
setupStep: 0,
|
||||
streamerMode: false,
|
||||
wallpaper: 'midnight',
|
||||
wallpaperHistory: [{ imageUrl: null, wallpaper: 'midnight' }],
|
||||
@@ -342,6 +347,20 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
10,
|
||||
100,
|
||||
),
|
||||
setupCompleted:
|
||||
typeof settings.setupCompleted === 'boolean'
|
||||
? settings.setupCompleted
|
||||
: true,
|
||||
setupStep: Math.floor(
|
||||
readNumber(
|
||||
settings.setupStep,
|
||||
typeof settings.setupCompleted === 'boolean'
|
||||
? defaults.setupStep
|
||||
: PHONE_SETUP_LAST_STEP,
|
||||
0,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
),
|
||||
),
|
||||
streamerMode: readBoolean(settings.streamerMode, defaults.streamerMode),
|
||||
wallpaper,
|
||||
wallpaperHistory:
|
||||
|
||||
@@ -46,4 +46,12 @@ describe('SettingsApp Sky UI contract', () => {
|
||||
/\.settings-wallpaper-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,/,
|
||||
)
|
||||
})
|
||||
|
||||
it('places destructive erasure behind a dedicated reset explanation', () => {
|
||||
expect(source).toContain("openView('reset')")
|
||||
expect(source).toContain("activeView === 'reset'")
|
||||
expect(source).toContain("phone.t('Apps.settings.keepSimData')")
|
||||
expect(source).toContain("phone.t('Apps.settings.keepCloudData')")
|
||||
expect(source).toContain('phone.resetAfterFactoryReset()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Bluetooth,
|
||||
Camera,
|
||||
Check,
|
||||
Cloud,
|
||||
EyeOff,
|
||||
KeyRound,
|
||||
Images,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Plane,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Signal,
|
||||
Smartphone,
|
||||
Sun,
|
||||
@@ -41,6 +43,7 @@ import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import PhonePasscode from '@/components/PhonePasscode.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useAppAuthStore } from '@/stores/app-auth'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import type {
|
||||
LaunchablePhoneAppDefinition,
|
||||
LaunchablePhoneAppId,
|
||||
@@ -60,7 +63,6 @@ import {
|
||||
SkyField,
|
||||
SkyLink,
|
||||
SkyNavbar,
|
||||
SkyProgress,
|
||||
SkyScrollArea,
|
||||
SkySearchbar,
|
||||
SkySegmented,
|
||||
@@ -102,6 +104,7 @@ type SettingsView =
|
||||
| 'general'
|
||||
| 'appearance'
|
||||
| 'wallpaper'
|
||||
| 'reset'
|
||||
type RootToggleKey =
|
||||
| 'airplaneMode'
|
||||
| 'streamerMode'
|
||||
@@ -119,7 +122,8 @@ type PasscodeFlow =
|
||||
| 'disable'
|
||||
| null
|
||||
|
||||
const FACTORY_RESET_DURATION_MS = 60_000
|
||||
const FACTORY_RESET_DURATION_MS = 8_000
|
||||
const FACTORY_RESET_RING_CIRCUMFERENCE = 289.03
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const mediaPicker = useMessageMediaStore()
|
||||
@@ -128,6 +132,7 @@ const router = useRouter()
|
||||
const isDevelopment = import.meta.env.DEV
|
||||
const account = useAccountStore()
|
||||
const appAuth = useAppAuthStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const query = ref('')
|
||||
const activeView = ref<SettingsView>('root')
|
||||
const selectedNotificationAppId = ref<LaunchablePhoneAppId>('calculator')
|
||||
@@ -156,6 +161,43 @@ const factoryResetProgress = ref(0)
|
||||
const selectedFrameColor = computed(
|
||||
() => PHONE_FRAME_COLORS[phone.preferences.settings.frame],
|
||||
)
|
||||
const factoryResetPhase = computed(() => {
|
||||
if (factoryResetProgress.value < 18) {
|
||||
return {
|
||||
detail: phone.t('Apps.settings.factoryResetPreparingDetail'),
|
||||
index: 0,
|
||||
label: phone.t('Apps.settings.factoryResetPreparing'),
|
||||
}
|
||||
}
|
||||
if (factoryResetProgress.value < 72) {
|
||||
return {
|
||||
detail: phone.t('Apps.settings.factoryResetRemovingDetail'),
|
||||
index: 1,
|
||||
label: phone.t('Apps.settings.factoryResetRemoving'),
|
||||
}
|
||||
}
|
||||
if (factoryResetProgress.value < 94) {
|
||||
return {
|
||||
detail: phone.t('Apps.settings.factoryResetSecuringDetail'),
|
||||
index: 2,
|
||||
label: phone.t('Apps.settings.factoryResetSecuring'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
detail: phone.t('Apps.settings.factoryResetFinishingDetail'),
|
||||
index: 3,
|
||||
label: phone.t('Apps.settings.factoryResetFinishing'),
|
||||
}
|
||||
})
|
||||
const factoryResetSecondsRemaining = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(FACTORY_RESET_DURATION_MS * (1 - factoryResetProgress.value / 100)) /
|
||||
1000,
|
||||
),
|
||||
),
|
||||
)
|
||||
let factoryResetAnimationFrame: number | undefined
|
||||
|
||||
const wallpaperHistory = computed(
|
||||
@@ -613,7 +655,11 @@ async function confirmFactoryReset(): Promise<void> {
|
||||
factoryResetProgress.value = 100
|
||||
factoryResetting.value = false
|
||||
if (!success) accountToast.value = accountError()
|
||||
else appAuth.hydrate(undefined, '')
|
||||
else {
|
||||
appAuth.hydrate(undefined, '')
|
||||
appStore.hydrate(undefined)
|
||||
phone.resetAfterFactoryReset()
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSimEject(): Promise<void> {
|
||||
@@ -1284,17 +1330,61 @@ onBeforeUnmount(() => {
|
||||
:title="phone.t('Apps.settings.ejectSim')"
|
||||
@activate="simEjectOpened = true"
|
||||
/>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<SkySettingsGroup>
|
||||
<SkySettingsRow
|
||||
kind="navigation"
|
||||
:title="phone.t('Apps.settings.transferOrReset')"
|
||||
:description="phone.t('Apps.settings.transferOrResetDescription')"
|
||||
@activate="openView('reset')"
|
||||
>
|
||||
<template #leading>
|
||||
<SkySettingsIcon color="#8e8e93">
|
||||
<RotateCcw :size="18" aria-hidden="true" />
|
||||
</SkySettingsIcon>
|
||||
</template>
|
||||
</SkySettingsRow>
|
||||
</SkySettingsGroup>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'reset'">
|
||||
<section class="settings-reset-hero">
|
||||
<div class="settings-reset-hero__icon">
|
||||
<RotateCcw :size="35" :stroke-width="1.65" />
|
||||
</div>
|
||||
<h2>{{ phone.t('Apps.settings.resetHeroTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.settings.resetHeroBody') }}</p>
|
||||
</section>
|
||||
|
||||
<SkySettingsGroup :title="phone.t('Apps.settings.erasedFromPhone')">
|
||||
<SkySettingsRow :title="phone.t('Apps.settings.eraseDeviceSettings')" />
|
||||
<SkySettingsRow :title="phone.t('Apps.settings.eraseLocalContent')" />
|
||||
<SkySettingsRow :title="phone.t('Apps.settings.eraseLocalApps')" />
|
||||
</SkySettingsGroup>
|
||||
|
||||
<SkySettingsGroup :title="phone.t('Apps.settings.keptSafe')">
|
||||
<SkySettingsRow
|
||||
:title="phone.t('Apps.settings.keepSimData')"
|
||||
:description="phone.t('Apps.settings.keepSimDataBody')"
|
||||
/>
|
||||
<SkySettingsRow
|
||||
:title="phone.t('Apps.settings.keepCloudData')"
|
||||
:description="phone.t('Apps.settings.keepCloudDataBody')"
|
||||
/>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<SkySettingsGroup>
|
||||
<SkySettingsRow
|
||||
kind="action"
|
||||
tone="danger"
|
||||
:title="phone.t('Apps.settings.factoryReset')"
|
||||
@activate="resetOpened = true"
|
||||
>
|
||||
<template #leading>
|
||||
<RotateCcw :size="18" aria-hidden="true" />
|
||||
</template>
|
||||
</SkySettingsRow>
|
||||
/>
|
||||
</SkySettingsGroup>
|
||||
<p class="settings-reset-footnote">
|
||||
{{ phone.t('Apps.settings.resetSetupAssistant') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'appearance'">
|
||||
@@ -1487,15 +1577,73 @@ onBeforeUnmount(() => {
|
||||
class="settings-reset-overlay"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="settings-reset-progress">
|
||||
<strong>{{ Math.floor(factoryResetProgress) }}%</strong>
|
||||
<SkyProgress
|
||||
:label="phone.t('Apps.settings.factoryResetProgress')"
|
||||
:progress="factoryResetProgress / 100"
|
||||
/>
|
||||
<div class="settings-reset-ambient" aria-hidden="true">
|
||||
<span v-for="particle in 8" :key="particle" />
|
||||
</div>
|
||||
|
||||
<div class="settings-reset-content">
|
||||
<div class="settings-reset-eyebrow">
|
||||
<ShieldCheck :size="13" :stroke-width="2.2" />
|
||||
{{ phone.t('Apps.settings.factoryResetSystemProcess') }}
|
||||
</div>
|
||||
|
||||
<div class="settings-reset-orbit" aria-hidden="true">
|
||||
<svg viewBox="0 0 104 104">
|
||||
<circle class="settings-reset-ring-track" cx="52" cy="52" r="46" />
|
||||
<circle
|
||||
class="settings-reset-ring-value"
|
||||
cx="52"
|
||||
cy="52"
|
||||
r="46"
|
||||
:stroke-dasharray="FACTORY_RESET_RING_CIRCUMFERENCE"
|
||||
:stroke-dashoffset="
|
||||
FACTORY_RESET_RING_CIRCUMFERENCE *
|
||||
(1 - factoryResetProgress / 100)
|
||||
"
|
||||
/>
|
||||
</svg>
|
||||
<div class="settings-reset-device">
|
||||
<Smartphone :size="37" :stroke-width="1.45" />
|
||||
<span class="settings-reset-device__scan" />
|
||||
</div>
|
||||
<span class="settings-reset-orbit__pulse" />
|
||||
</div>
|
||||
|
||||
<div class="settings-reset-heading">
|
||||
<strong>{{ Math.floor(factoryResetProgress) }}%</strong>
|
||||
<h2>{{ phone.t('Apps.settings.factoryResetProgress') }}</h2>
|
||||
<p>{{ factoryResetPhase.label }}</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-reset-progress" role="progressbar" :aria-valuenow="Math.floor(factoryResetProgress)" aria-valuemin="0" aria-valuemax="100">
|
||||
<span :style="{ width: `${factoryResetProgress}%` }" />
|
||||
</div>
|
||||
|
||||
<div class="settings-reset-phase-card">
|
||||
<div class="settings-reset-phase-card__topline">
|
||||
<span>{{ factoryResetPhase.detail }}</span>
|
||||
<strong>{{ factoryResetSecondsRemaining }}s</strong>
|
||||
</div>
|
||||
<div class="settings-reset-steps" aria-hidden="true">
|
||||
<span
|
||||
v-for="step in 4"
|
||||
:key="step"
|
||||
:class="{
|
||||
'settings-reset-step--active': step - 1 === factoryResetPhase.index,
|
||||
'settings-reset-step--complete': step - 1 < factoryResetPhase.index,
|
||||
}"
|
||||
>
|
||||
<Check v-if="step - 1 < factoryResetPhase.index" :size="9" :stroke-width="3" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-reset-protected">
|
||||
<span><Signal :size="14" /> {{ phone.t('Apps.settings.factoryResetSimSafe') }}</span>
|
||||
<span><Cloud :size="14" /> {{ phone.t('Apps.settings.factoryResetCloudSafe') }}</span>
|
||||
</div>
|
||||
<p class="settings-reset-warning">{{ phone.t('Apps.settings.factoryResetWarning') }}</p>
|
||||
</div>
|
||||
<h2>{{ phone.t('Apps.settings.factoryResetProgress') }}</h2>
|
||||
<p>{{ phone.t('Apps.settings.factoryResetWarning') }}</p>
|
||||
</div>
|
||||
|
||||
<SkyDialog
|
||||
@@ -1637,6 +1785,47 @@ onBeforeUnmount(() => {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.settings-reset-hero {
|
||||
padding: 18px 24px 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-reset-hero__icon {
|
||||
display: grid;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
margin: 0 auto 16px;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 24px;
|
||||
color: #ff665f;
|
||||
background: linear-gradient(145deg, rgb(255 74 66 / 18%), rgb(255 255 255 / 5%));
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 12%), 0 18px 35px rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
.settings-reset-hero h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.settings-reset-hero p,
|
||||
.settings-reset-footnote {
|
||||
color: var(--sky-color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.settings-reset-hero p {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.settings-reset-footnote {
|
||||
margin: -3px 20px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-copy {
|
||||
margin: 0 var(--sky-space-1) var(--sky-space-4);
|
||||
color: var(--sky-muted);
|
||||
@@ -1950,34 +2139,263 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sky-space-6);
|
||||
background: #000000;
|
||||
overflow: hidden;
|
||||
padding: 34px 26px 24px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgb(19 92 164 / 22%), transparent 34%),
|
||||
radial-gradient(circle at 50% 100%, rgb(8 57 107 / 18%), transparent 40%),
|
||||
#000000;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-reset-progress {
|
||||
width: min(240px, 80%);
|
||||
.settings-reset-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 310px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-reset-progress strong {
|
||||
.settings-reset-ambient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-reset-ambient span {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 50%;
|
||||
background: #66b9ff;
|
||||
box-shadow: 0 0 8px 2px rgb(43 153 255 / 45%);
|
||||
opacity: 0.5;
|
||||
animation: settings-reset-particle 3.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.settings-reset-ambient span:nth-child(1) { top: 23%; left: 17%; }
|
||||
.settings-reset-ambient span:nth-child(2) { top: 31%; right: 13%; animation-delay: -1.2s; }
|
||||
.settings-reset-ambient span:nth-child(3) { top: 48%; left: 9%; animation-delay: -2.4s; }
|
||||
.settings-reset-ambient span:nth-child(4) { top: 56%; right: 8%; animation-delay: -0.6s; }
|
||||
.settings-reset-ambient span:nth-child(5) { bottom: 24%; left: 21%; animation-delay: -3s; }
|
||||
.settings-reset-ambient span:nth-child(6) { bottom: 19%; right: 18%; animation-delay: -1.7s; }
|
||||
.settings-reset-ambient span:nth-child(7) { top: 16%; right: 30%; animation-delay: -2.1s; }
|
||||
.settings-reset-ambient span:nth-child(8) { bottom: 34%; left: 33%; animation-delay: -0.9s; }
|
||||
|
||||
.settings-reset-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 6%);
|
||||
color: rgb(255 255 255 / 70%);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-reset-orbit {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
margin-top: 26px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.settings-reset-orbit svg {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
overflow: visible;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.settings-reset-ring-track,
|
||||
.settings-reset-ring-value {
|
||||
fill: none;
|
||||
stroke-width: 2.6;
|
||||
}
|
||||
|
||||
.settings-reset-ring-track { stroke: rgb(255 255 255 / 10%); }
|
||||
.settings-reset-ring-value {
|
||||
stroke: #2997ff;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 6px rgb(41 151 255 / 75%));
|
||||
transition: stroke-dashoffset 120ms linear;
|
||||
}
|
||||
|
||||
.settings-reset-device {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 23px;
|
||||
background: linear-gradient(145deg, rgb(255 255 255 / 11%), rgb(255 255 255 / 3%));
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 10%), 0 18px 36px rgb(0 0 0 / 38%);
|
||||
color: rgb(255 255 255 / 88%);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.settings-reset-device__scan {
|
||||
position: absolute;
|
||||
right: 13px;
|
||||
left: 13px;
|
||||
height: 1px;
|
||||
background: #42a5ff;
|
||||
box-shadow: 0 0 9px 2px rgb(41 151 255 / 65%);
|
||||
animation: settings-reset-scan 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.settings-reset-orbit__pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid rgb(41 151 255 / 20%);
|
||||
border-radius: 50%;
|
||||
animation: settings-reset-pulse 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.settings-reset-heading { margin-top: 19px; }
|
||||
.settings-reset-heading > strong {
|
||||
display: block;
|
||||
margin-bottom: var(--sky-space-3);
|
||||
font-size: 28px;
|
||||
font-size: 34px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-reset-heading h2 {
|
||||
margin: 10px 0 0;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.settings-reset-heading p {
|
||||
margin: 5px 0 0;
|
||||
color: #66b5ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-reset-progress {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
margin-top: 22px;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.settings-reset-progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #087cff, #55b8ff);
|
||||
box-shadow: 0 0 10px rgb(41 151 255 / 60%);
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
|
||||
.settings-reset-phase-card {
|
||||
width: 100%;
|
||||
margin-top: 13px;
|
||||
padding: 12px 13px;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: 15px;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.settings-reset-phase-card__topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: rgb(255 255 255 / 68%);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-reset-phase-card__topline strong {
|
||||
color: rgb(255 255 255 / 88%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.settings-reset-overlay h2 {
|
||||
margin: var(--sky-space-5) 0 0;
|
||||
font-size: 20px;
|
||||
.settings-reset-steps {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.settings-reset-overlay p {
|
||||
.settings-reset-steps > span {
|
||||
display: grid;
|
||||
height: 4px;
|
||||
flex: 1;
|
||||
border-radius: 99px;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
color: #ffffff;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.settings-reset-steps > .settings-reset-step--active {
|
||||
background: #2997ff;
|
||||
box-shadow: 0 0 7px rgb(41 151 255 / 55%);
|
||||
}
|
||||
|
||||
.settings-reset-steps > .settings-reset-step--complete {
|
||||
height: 12px;
|
||||
margin-top: -4px;
|
||||
border-radius: 50%;
|
||||
background: #2997ff;
|
||||
}
|
||||
|
||||
.settings-reset-protected {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.settings-reset-protected span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid rgb(85 190 129 / 15%);
|
||||
border-radius: 999px;
|
||||
background: rgb(51 199 89 / 8%);
|
||||
color: rgb(120 226 160 / 88%);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-reset-warning {
|
||||
max-width: 270px;
|
||||
margin: var(--sky-space-2) 0 0;
|
||||
color: rgb(255 255 255 / 62%);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
margin: 11px 0 0;
|
||||
color: rgb(255 255 255 / 42%);
|
||||
font-size: 9px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
@keyframes settings-reset-scan {
|
||||
0%, 100% { transform: translateY(-18px); opacity: 0.25; }
|
||||
50% { transform: translateY(18px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes settings-reset-pulse {
|
||||
0% { transform: scale(0.82); opacity: 0; }
|
||||
28% { opacity: 0.7; }
|
||||
100% { transform: scale(1.18); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes settings-reset-particle {
|
||||
0%, 100% { transform: translateY(0) scale(0.75); opacity: 0.2; }
|
||||
50% { transform: translateY(-12px) scale(1); opacity: 0.7; }
|
||||
}
|
||||
|
||||
.settings-dialog-button--danger {
|
||||
|
||||
@@ -7969,12 +7969,14 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'development:bootstrap') {
|
||||
authenticated = true
|
||||
linkedAccount = {
|
||||
devices: accountDevices,
|
||||
email: 'demo@ifruit.com',
|
||||
id: 1,
|
||||
}
|
||||
authenticated = testScenario !== 'setup-account-unlinked'
|
||||
linkedAccount = authenticated
|
||||
? {
|
||||
devices: accountDevices,
|
||||
email: 'demo@ifruit.com',
|
||||
id: 1,
|
||||
}
|
||||
: null
|
||||
if (
|
||||
testScenario === 'feather-onboarding' ||
|
||||
testScenario === 'feather-register'
|
||||
|
||||
@@ -70,8 +70,59 @@ Locales["en"] = {
|
||||
elapsed = "TIME",
|
||||
cost = "COST",
|
||||
},
|
||||
Setup = {
|
||||
title = "Sky Phone Setup Assistant", ownerFallback = "Sky Phone Owner", getStarted = "Get Started", setUpLater = "Set Up Later",
|
||||
welcome = { title = "Welcome to Sky Phone", eyebrow = "Designed around you", hello = "hello", hallo = "hallo", bonjour = "bonjour", body = "Hello, {name}. Let's make this phone unmistakably yours.", private = "Private by design", personal = "Made for you" },
|
||||
connection = {
|
||||
eyebrow = "Mobile Connection", title = "Your connection is ready", body = "Sky Phone automatically detects the SIM installed in this device.",
|
||||
noSim = "No SIM installed", ready = "Connected to the Sky network", offline = "Phone works offline; calls and messages require a SIM",
|
||||
preserved = "Your SIM number and SIM-backed conversations remain with the SIM, even if this phone is erased.",
|
||||
},
|
||||
cloud = {
|
||||
eyebrow = "Sky Cloud", title = "Keep important data with you", body = "Sign in to sync supported app data and recover it on another Sky Phone.",
|
||||
signIn = "Sign In", create = "Create Account", email = "Sky Cloud Email", password = "Password", connected = "Sky Cloud connected",
|
||||
signInTitle = "Sign in to Sky Cloud", createTitle = "Create your Sky Cloud account",
|
||||
signInBody = "Access your protected data, purchases and settings on this phone.", createBody = "Choose your personal iFruit address and keep important data available across devices.",
|
||||
accountPreview = "Your Sky Cloud ID", accountName = "Account name", addressPlaceholder = "alex.morgan", confirmPassword = "Confirm Password",
|
||||
passwordMismatch = "The passwords do not match.", strength0 = "At least 6 characters", strength1 = "Basic password", strength2 = "Good password", strength3 = "Strong password", strength4 = "Excellent password",
|
||||
signInAction = "Sign In Securely", createAction = "Create Sky Cloud Account", securityNote = "Encrypted connection · Your password stays private",
|
||||
invalid = "Enter a valid email and a password with at least 6 characters.",
|
||||
errors = {
|
||||
invalid_email = "Enter a valid Sky Cloud email.", invalid_password = "Your password must contain at least 6 characters.",
|
||||
invalid_credentials = "Email or password is incorrect.", email_taken = "This Sky Cloud email is already registered.",
|
||||
rate_limited = "Too many attempts. Try again shortly.", request_failed = "Sky Cloud is temporarily unavailable.",
|
||||
},
|
||||
},
|
||||
security = {
|
||||
eyebrow = "Privacy & Security", title = "Protect this phone", body = "Choose a four- or six-digit passcode to keep your apps and local information private.",
|
||||
local = "The passcode belongs to this physical phone and is never transferred with the SIM.", create = "Create Passcode", createSelected = "Create {count}-Digit Passcode",
|
||||
lengthTitle = "Choose passcode length", fourDigit = "4-Digit Code", sixDigit = "6-Digit Code",
|
||||
enter = "Create a Passcode", confirm = "Verify Your Passcode", codeHint = "Enter a memorable six-digit code.", fourDigitHint = "Enter a memorable four-digit code.", sixDigitHint = "Enter a memorable six-digit code.",
|
||||
mismatch = "The passcodes did not match. Try again.", failed = "The passcode could not be saved.",
|
||||
},
|
||||
appearance = { eyebrow = "Display", title = "Choose your appearance", body = "Automatic follows your system. You can change this at any time in Settings." },
|
||||
performance = {
|
||||
eyebrow = "Performance", title = "Choose how your phone feels", body = "Balance responsiveness and visual effects for your FiveM setup.",
|
||||
performance = "Fastest response with optimized, blur-free glass.", ultimate = "Rich depth, live blur and the complete glass experience.",
|
||||
changeLater = "You can switch modes later under Settings › Appearance.",
|
||||
},
|
||||
wallpaper = { eyebrow = "Personalize", title = "Make it yours", body = "Choose one of twelve crafted Sky Phone backgrounds." },
|
||||
notifications = {
|
||||
eyebrow = "Stay Informed", title = "Notifications your way", body = "Choose how apps may reach you. Critical alarms and calls stay available.",
|
||||
allow = "Allow App Notifications", allowBody = "Show banners, lock-screen updates and badges.",
|
||||
sounds = "Notification Sounds", soundsBody = "Play a subtle sound for incoming updates.",
|
||||
},
|
||||
apps = {
|
||||
eyebrow = "Suggested for You", title = "Start with your favorites", body = "Choose optional apps. Every essential phone app is already included.", install = "Add {count} Apps",
|
||||
descriptions = { banking = "Secure city banking", garage = "Your vehicles at a glance", skyride = "Request a ride", citymarkt = "Shop local listings", picstagram = "Share photos", snake = "A timeless game" },
|
||||
},
|
||||
ready = {
|
||||
eyebrow = "Setup Complete", title = "Welcome, {name}", body = "Your Sky Phone is configured and ready. Your choices can always be refined in Settings.",
|
||||
localOnly = "Stored on this phone", enter = "Enter Sky Phone", review = "Review Setup",
|
||||
},
|
||||
},
|
||||
Common = {
|
||||
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
|
||||
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", continue = "Continue", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
|
||||
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
|
||||
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
|
||||
signOut = "Sign Out", signingOut = "Signing Out...", signOutTitle = "Sign out of {app}?",
|
||||
@@ -1763,8 +1814,21 @@ Locales["en"] = {
|
||||
simCard = "SIM Card", simNumber = "Phone Number", simType = "SIM Type", registeredSim = "Registered",
|
||||
anonymousSim = "Anonymous", noSim = "No SIM", ejectSim = "Eject SIM", ejectSimBody = "Return this SIM card to your inventory?",
|
||||
removeDeviceBody = "Enter your Sky Cloud password to remove this device from the account.", signOut = "Sign Out",
|
||||
factoryReset = "Erase All Content and Settings", factoryResetBody = "This removes the account and all local data from this phone. Cloud data and the IMEI remain.",
|
||||
factoryResetProgress = "Erasing iFruit Phone", factoryResetWarning = "Do not turn off this phone. This takes 60 seconds.",
|
||||
reset = "Transfer or Reset Phone", transferOrReset = "Transfer or Reset Phone", transferOrResetDescription = "Prepare this phone for a fresh setup",
|
||||
resetHeroTitle = "A clean start, without losing what follows you",
|
||||
resetHeroBody = "Erase local content and settings from this phone. Information attached to your SIM or stored in supported accounts remains available.",
|
||||
erasedFromPhone = "Erased From This Phone", eraseDeviceSettings = "Passcode, preferences and Home Screen layout",
|
||||
eraseLocalContent = "Photos, notes and content stored only locally", eraseLocalApps = "Downloaded apps and local app sessions",
|
||||
keptSafe = "Kept Safe", keepSimData = "SIM Card & Phone Number", keepSimDataBody = "Your number and SIM-backed communication remain on the SIM.",
|
||||
keepCloudData = "Account & Cloud Data", keepCloudDataBody = "Supported information remains in its app or Sky Cloud account.",
|
||||
resetSetupAssistant = "When erasing is complete, Setup Assistant starts automatically.",
|
||||
factoryReset = "Erase All Content and Settings", factoryResetBody = "This cannot be undone. Local settings, apps and unsynced content are erased. Your SIM number and account-backed data remain available.",
|
||||
factoryResetProgress = "Erasing Sky Phone", factoryResetWarning = "Keep this phone open. Your SIM and cloud data are safe.",
|
||||
factoryResetSystemProcess = "Secure system process", factoryResetPreparing = "Preparing secure reset", factoryResetPreparingDetail = "Checking device data",
|
||||
factoryResetRemoving = "Removing personal data", factoryResetRemovingDetail = "Clearing apps and local content",
|
||||
factoryResetSecuring = "Protecting linked services", factoryResetSecuringDetail = "Preserving SIM and cloud data",
|
||||
factoryResetFinishing = "Finishing setup", factoryResetFinishingDetail = "Preparing the welcome screen",
|
||||
factoryResetSimSafe = "SIM protected", factoryResetCloudSafe = "Cloud protected",
|
||||
passcode = {
|
||||
description = "A passcode protects the contents of this phone. It stays with the device when the SIM or Sky Cloud account changes.",
|
||||
status = "Passcode", codeLength = "Code Length", sixDigit = "6-Digit Code", fourDigit = "4-Digit Code",
|
||||
|
||||
Reference in New Issue
Block a user