From e033271cb22bec40839008127642058e8730d88b Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:35:27 +0200 Subject: [PATCH] ADD - introduce advanced phone setup and reset flow --- frontend/package.json | 1 + frontend/src/App.vue | 43 +- .../AppDevelopmentPreview.contract.test.ts | 7 +- .../PhoneSetupAssistant.contract.test.ts | 28 + .../src/components/PhoneSetupAssistant.vue | 1763 +++++++++++++++++ frontend/src/components/PhoneStatusBar.vue | 6 +- frontend/src/stores/phone.ts | 192 +- frontend/src/utils/preferences.test.ts | 29 + frontend/src/utils/preferences.ts | 19 + .../views/apps/SettingsApp.contract.test.ts | 8 + frontend/src/views/apps/SettingsApp.vue | 480 ++++- frontend/testserver/index.cjs | 14 +- sky_phone/config/locales/en.lua | 70 +- 13 files changed, 2606 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/PhoneSetupAssistant.contract.test.ts create mode 100644 frontend/src/components/PhoneSetupAssistant.vue diff --git a/frontend/package.json b/frontend/package.json index 566a5a6..e97147b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 348d9f1..830a8ff 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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(null) const unlockedServicesLoaded = ref(false) const controlCenterOpened = ref(false) const activitySuspended = ref(false) const simPicker = ref(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 { const response = await nuiCall('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" /> - + - + { { @complete="submitUnlockPasscode" /> + { "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()') }) }) diff --git a/frontend/src/components/PhoneSetupAssistant.contract.test.ts b/frontend/src/components/PhoneSetupAssistant.contract.test.ts new file mode 100644 index 0000000..98054a9 --- /dev/null +++ b/frontend/src/components/PhoneSetupAssistant.contract.test.ts @@ -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)"') + }) +}) diff --git a/frontend/src/components/PhoneSetupAssistant.vue b/frontend/src/components/PhoneSetupAssistant.vue new file mode 100644 index 0000000..26d22b8 --- /dev/null +++ b/frontend/src/components/PhoneSetupAssistant.vue @@ -0,0 +1,1763 @@ + + + + + diff --git a/frontend/src/components/PhoneStatusBar.vue b/frontend/src/components/PhoneStatusBar.vue index 99c7902..97afb97 100644 --- a/frontend/src/components/PhoneStatusBar.vue +++ b/frontend/src/components/PhoneStatusBar.vue @@ -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')" > { + 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( diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 4df8df1..7b00633 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -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: diff --git a/frontend/src/views/apps/SettingsApp.contract.test.ts b/frontend/src/views/apps/SettingsApp.contract.test.ts index 0049e8f..5075880 100644 --- a/frontend/src/views/apps/SettingsApp.contract.test.ts +++ b/frontend/src/views/apps/SettingsApp.contract.test.ts @@ -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()') + }) }) diff --git a/frontend/src/views/apps/SettingsApp.vue b/frontend/src/views/apps/SettingsApp.vue index 4f31e69..5a0ac3a 100644 --- a/frontend/src/views/apps/SettingsApp.vue +++ b/frontend/src/views/apps/SettingsApp.vue @@ -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('root') const selectedNotificationAppId = ref('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 { 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 { @@ -1284,17 +1330,61 @@ onBeforeUnmount(() => { :title="phone.t('Apps.settings.ejectSim')" @activate="simEjectOpened = true" /> + + + + + + + + + +