ADD - introduce advanced phone setup and reset flow

This commit is contained in:
smx.pusha
2026-08-14 14:35:27 +02:00
parent 5d494419da
commit e033271cb2
13 changed files with 2606 additions and 54 deletions
+1
View File
@@ -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
View File
@@ -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
+5 -1
View File
@@ -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"
+189 -3
View File
@@ -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}. Lets 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
},
+29
View File
@@ -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(
+19
View File
@@ -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()')
})
})
+449 -31
View File
@@ -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 {
+8 -6
View File
@@ -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'
+67 -3
View File
@@ -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",