ADD - connect optional iFruit account

This commit is contained in:
Eichenholz
2026-08-04 18:36:04 +02:00
parent 46e6c6cc25
commit a8a4076896
18 changed files with 733 additions and 129 deletions
+32 -2
View File
@@ -16,6 +16,10 @@ import PhoneNotifications from '@/components/PhoneNotifications.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { useClockStore } from '@/stores/clock'
import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import { useMediaStore } from '@/stores/media'
import { useNotesStore } from '@/stores/notes'
import {
useNotificationsStore,
type PhoneNotificationInput,
@@ -36,7 +40,11 @@ const PHONE_BASE_SCALE = 0.69
const isDevelopment = import.meta.env.DEV
const phone = usePhoneStore()
const account = useAccountStore()
const clock = useClockStore()
const mail = useMailStore()
const media = useMediaStore()
const notes = useNotesStore()
const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
@@ -66,9 +74,20 @@ function getViewportScale(): number {
return Math.min(window.innerWidth / REFERENCE_VIEWPORT_WIDTH, heightScale)
}
function hydratePhone(payload: PhoneOpenPayload): void {
phone.open(payload)
account.hydrate(payload.account ?? null)
notes.hydrate(payload.notes ?? [])
clock.hydrate(payload.device?.data.alarms?.payload)
media.hydrate(payload.device?.data.media?.payload)
void mail.bootstrap(payload.account?.email ?? '')
}
function onMessage(event: MessageEvent<AppMessage>): void {
if (event.data?.type === 'app:open') {
phone.open(event.data.data as PhoneOpenPayload)
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'device:updated') {
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
phone.close()
} else if (event.data?.type === 'notification:show' && event.data.data) {
@@ -140,7 +159,18 @@ onMounted(() => {
})
}
}, 1000)
if (isDevelopment) phone.open()
if (isDevelopment) {
hydratePhone({
account: null,
device: {
data: {},
imei: '356938035643809',
name: 'iFruit Phone',
},
notes: [],
token: 'development',
})
}
})
watch(
+73
View File
@@ -0,0 +1,73 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccountStore } from '@/stores/account'
import type { AccountDevice } from '@/types/device'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const mockNuiCall = vi.mocked(nuiCall)
const devices: AccountDevice[] = [
{
created_at: '2026-08-04 10:00:00',
current: true,
device_name: 'iFruit Phone',
imei: '356938035643809',
updated_at: '2026-08-04 10:00:00',
},
]
describe('account store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('links the whole device after account login', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { devices, email: 'alex@ifruit.com' },
success: true,
})
const account = useAccountStore()
const response = await account.login('alex', 'roleplay')
expect(response.success).toBe(true)
expect(account.email).toBe('alex@ifruit.com')
expect(account.devices).toEqual(devices)
expect(mockNuiCall).toHaveBeenCalledWith('account:login', {
email: 'alex',
password: 'roleplay',
})
})
it('keeps the existing account state when credentials fail', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'invalid_credentials',
success: false,
})
const account = useAccountStore()
account.hydrate({ devices, email: 'alex@ifruit.com' })
await account.login('alex', 'wrong-password')
expect(account.email).toBe('alex@ifruit.com')
expect(account.devices).toEqual(devices)
})
it('clears account state after a factory reset', async () => {
mockNuiCall.mockResolvedValueOnce({ success: true })
const account = useAccountStore()
account.hydrate({ devices, email: 'alex@ifruit.com' })
const success = await account.factoryReset()
expect(success).toBe(true)
expect(account.email).toBe('')
expect(account.devices).toEqual([])
expect(mockNuiCall).toHaveBeenCalledWith('device:factory-reset')
})
})
+56
View File
@@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
import type { AccountDevice, IfruitAccount } from '@/types/device'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useAccountStore = defineStore('account', {
state: () => ({
devices: [] as AccountDevice[],
email: '',
}),
actions: {
hydrate(account: IfruitAccount | null): void {
this.email = account?.email ?? ''
this.devices = account?.devices ?? []
},
async login(email: string, password: string): Promise<NuiResponse<IfruitAccount>> {
const response = await nuiCall<IfruitAccount>('account:login', {
email,
password,
})
if (response.success && response.data) this.hydrate(response.data)
return response
},
async register(email: string, password: string): Promise<NuiResponse<IfruitAccount>> {
const response = await nuiCall<IfruitAccount>('account:register', {
email,
password,
})
if (response.success && response.data) this.hydrate(response.data)
return response
},
async logout(): Promise<boolean> {
const response = await nuiCall('account:logout')
if (response.success) this.hydrate(null)
return response.success
},
async loadDevices(): Promise<boolean> {
const response = await nuiCall<AccountDevice[]>('account:devices')
if (response.success && response.data) this.devices = response.data
return response.success
},
async removeDevice(imei: string, password: string): Promise<NuiResponse<AccountDevice[]>> {
const response = await nuiCall<AccountDevice[]>('account:remove-device', {
imei,
password,
})
if (response.success && response.data) this.devices = response.data
return response
},
async factoryReset(): Promise<boolean> {
const response = await nuiCall('device:factory-reset')
if (response.success) this.hydrate(null)
return response.success
},
},
})
+8 -4
View File
@@ -5,15 +5,16 @@ import {
type Alarm,
type AlarmDraft,
type AlarmSoundId,
DEFAULT_ALARMS,
isAlarmDue,
readAlarms,
writeAlarms,
parseAlarms,
} from '@/utils/alarms'
import { elapsedMilliseconds, remainingMilliseconds } from '@/utils/clock'
import { usePhoneStore } from '@/stores/phone'
export const useClockStore = defineStore('clock', {
state: () => ({
alarms: readAlarms(),
alarms: structuredClone(DEFAULT_ALARMS),
laps: [] as number[],
stopwatchAccumulated: 0,
stopwatchStartedAt: null as number | null,
@@ -81,7 +82,10 @@ export const useClockStore = defineStore('clock', {
this.timerStartedAt = null
},
persistAlarms(): void {
writeAlarms(this.alarms)
usePhoneStore().saveDeviceNamespace('alarms', this.alarms)
},
hydrate(alarms: unknown): void {
this.alarms = parseAlarms(alarms)
},
resetStopwatch(): void {
this.stopwatchAccumulated = 0
+21 -1
View File
@@ -88,9 +88,10 @@ describe('mail store', () => {
it('logs out the active session and resets mailbox state', async () => {
mockNuiCall
.mockResolvedValueOnce({
data: { counts, email: 'alex@ifruit.com' },
data: { devices: [], email: 'alex@ifruit.com' },
success: true,
})
.mockResolvedValueOnce({ data: counts, success: true })
.mockResolvedValueOnce({
data: { hasMore: true, items: [listItem(1)] },
success: true,
@@ -109,4 +110,23 @@ describe('mail store', () => {
expect(mail.items).toEqual([])
expect(mail.hasMore).toBe(false)
})
it('removes loaded cloud mail when the device becomes unlinked', async () => {
mockNuiCall
.mockResolvedValueOnce({ data: counts, success: true })
.mockResolvedValueOnce({
data: { hasMore: true, items: [listItem(1)] },
success: true,
})
const mail = useMailStore()
await mail.bootstrap('alex@ifruit.com')
await mail.loadFolder('sent', 'plans')
await mail.bootstrap('')
expect(mail.accountEmail).toBe('')
expect(mail.items).toEqual([])
expect(mail.folder).toBe('inbox')
expect(mail.search).toBe('')
})
})
+46 -26
View File
@@ -1,6 +1,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useAccountStore } from '@/stores/account'
import type { IfruitAccount } from '@/types/device'
import type {
MailComposeDraft,
MailCounts,
@@ -9,7 +11,6 @@ import type {
MailListItem,
MailListResponse,
MailMessage,
MailSession,
} from '@/types/mail'
import { nuiCall } from '@/utils/nui'
@@ -22,6 +23,7 @@ const emptyCounts = (): MailCounts => ({
})
export const useMailStore = defineStore('mail', () => {
const account = useAccountStore()
const accountEmail = ref('')
const counts = ref<MailCounts>(emptyCounts())
const folder = ref<MailFolder>('inbox')
@@ -30,31 +32,7 @@ export const useMailStore = defineStore('mail', () => {
const loading = ref(false)
const search = ref('')
function applySession(session: MailSession): void {
accountEmail.value = session.email
counts.value = session.counts
}
async function login(email: string, password: string) {
const response = await nuiCall<MailSession>('mail:login', {
email,
password,
})
if (response.success && response.data) applySession(response.data)
return response
}
async function register(email: string, password: string) {
const response = await nuiCall<MailSession>('mail:register', {
email,
password,
})
if (response.success && response.data) applySession(response.data)
return response
}
async function logout(): Promise<void> {
if (accountEmail.value) await nuiCall('mail:logout')
function clearSession(): void {
accountEmail.value = ''
counts.value = emptyCounts()
items.value = []
@@ -63,6 +41,47 @@ export const useMailStore = defineStore('mail', () => {
search.value = ''
}
async function bootstrap(email: string): Promise<void> {
if (!email) {
clearSession()
return
}
accountEmail.value = email
await refreshCounts()
}
async function login(email: string, password: string) {
const response = await nuiCall<IfruitAccount>('mail:login', {
email,
password,
})
if (response.success && response.data) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
return response
}
async function register(email: string, password: string) {
const response = await nuiCall<IfruitAccount>('mail:register', {
email,
password,
})
if (response.success && response.data) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
return response
}
async function logout(): Promise<void> {
if (accountEmail.value) {
const response = await nuiCall('mail:logout')
if (response.success) account.hydrate(null)
}
clearSession()
}
async function loadFolder(
nextFolder: MailFolder,
nextSearch = '',
@@ -160,6 +179,7 @@ export const useMailStore = defineStore('mail', () => {
return {
accountEmail,
bootstrap,
counts,
deleteDraft,
emptyTrash,
+23 -1
View File
@@ -1,5 +1,7 @@
import { defineStore } from 'pinia'
import { usePhoneStore } from '@/stores/phone'
export type PhonePhoto = {
capturedAt: number
gradient: string
@@ -59,9 +61,29 @@ export const useMediaStore = defineStore('media', {
id: `capture-${Date.now()}`,
titleKey: 'Apps.photos.samples.capture',
})
this.persist()
},
claimApp(id: string): void {
if (!this.claimedApps.includes(id)) this.claimedApps.push(id)
if (!this.claimedApps.includes(id)) {
this.claimedApps.push(id)
this.persist()
}
},
hydrate(payload: unknown): void {
const data = payload as Partial<{
captures: PhonePhoto[]
claimedApps: string[]
}> | null
this.captures = Array.isArray(data?.captures) ? data.captures : []
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter((id): id is string => typeof id === 'string')
: []
},
persist(): void {
usePhoneStore().saveDeviceNamespace('media', {
captures: this.captures,
claimedApps: this.claimedApps,
})
},
},
})
+20 -13
View File
@@ -1,15 +1,11 @@
import { defineStore } from 'pinia'
import {
type Note,
type NoteDraft,
readNotes,
writeNotes,
} from '@/utils/notes'
import { nuiCall } from '@/utils/nui'
import { type Note, type NoteDraft } from '@/utils/notes'
export const useNotesStore = defineStore('notes', {
state: () => ({
notes: readNotes(),
notes: [] as Note[],
}),
actions: {
createNote(draft: NoteDraft): Note {
@@ -19,30 +15,41 @@ export const useNotesStore = defineStore('notes', {
createdAt: now,
id: `note-${now}-${Math.random().toString(36).slice(2, 9)}`,
pinned: false,
revision: 1,
updatedAt: now,
}
this.notes.unshift(note)
this.persist()
void this.createRemote(note)
return note
},
deleteNote(id: string): void {
this.notes = this.notes.filter((note) => note.id !== id)
this.persist()
void nuiCall<Note[]>('notes:delete', { id }).then((response) => {
if (response.success && response.data) this.hydrate(response.data)
})
},
persist(): void {
writeNotes(this.notes)
hydrate(notes: Note[]): void {
this.notes = structuredClone(notes)
},
async createRemote(note: Note): Promise<void> {
const response = await nuiCall<Note[]>('notes:create', note)
if (response.data) this.hydrate(response.data)
},
togglePinned(id: string): void {
const note = this.notes.find((candidate) => candidate.id === id)
if (!note) return
note.pinned = !note.pinned
this.persist()
void this.updateRemote(note)
},
updateNote(id: string, draft: NoteDraft): void {
const note = this.notes.find((candidate) => candidate.id === id)
if (!note) return
Object.assign(note, draft, { updatedAt: Date.now() })
this.persist()
void this.updateRemote(note)
},
async updateRemote(note: Note): Promise<void> {
const response = await nuiCall<Note[]>('notes:update', note)
if (response.data) this.hydrate(response.data)
},
},
})
+73 -8
View File
@@ -1,22 +1,30 @@
import { defineStore } from 'pinia'
import type { AppLaunchOrigin, PhoneAppId } from '@/types/apps'
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
import { clampPage } from '@/utils/pages'
import { nuiCall } from '@/utils/nui'
import {
readPhonePreferences,
DEFAULT_PHONE_PREFERENCES,
parsePhonePreferences,
type AppNotificationPreferences,
type PhonePreferencesV1,
type WallpaperId,
writePhonePreferences,
} from '@/utils/preferences'
type LocaleTree = Record<string, unknown>
export type PhoneOpenPayload = {
account?: DeviceBootstrap['account']
device?: PhoneDevice
lang?: string
locales?: LocaleTree
notes?: DeviceBootstrap['notes']
token?: string
}
const namespaceQueues = new Map<string, Promise<void>>()
const defaultLocales: LocaleTree = {
Apps: {
appStore: {
@@ -275,8 +283,11 @@ const defaultLocales: LocaleTree = {
on: 'On',
off: 'Off',
accountName: 'iFruit Account',
accountDetail: 'Cloud, Media & Purchases',
accountLocalDetail: 'Local account for this phone',
accountDetail: 'Cloud, Mail & Notes',
accountLocalDetail: 'Not signed in',
accountCloudDetail: 'Mail and notes sync through iFruit Cloud',
accountLoginBody:
'Sign in is optional. This phone also works with local data only.',
accountInformation: 'Account Information',
accountStatus: 'Account Status',
accountStatusValue: 'Active',
@@ -310,6 +321,27 @@ const defaultLocales: LocaleTree = {
languageValue: 'English',
localStorage: 'Local Storage',
localStorageValue: 'On Device',
deviceInformation: 'Device Information',
imei: 'IMEI',
linkedDevices: 'Linked Devices',
thisDevice: 'This Phone',
removeDevice: 'Remove Device',
removeDeviceBody:
'Enter your iFruit 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.',
accountErrors: {
invalid_email: 'Choose a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
invalid_credentials: 'Email or password is incorrect.',
email_taken: 'That iFruit address is already registered.',
rate_limited: 'Too many attempts. Try again in a minute.',
current_device: 'Sign out instead of removing the current phone.',
device_not_found: 'That device is no longer linked.',
default: 'The account request failed.',
},
back: 'Settings',
wallpaperPicker: 'Built-in Wallpapers',
toggle: {
@@ -409,11 +441,13 @@ function getByPath(source: LocaleTree, path: string): unknown {
export const usePhoneStore = defineStore('phone', {
state: () => ({
currentPage: 1,
device: null as PhoneDevice | null,
deviceRevisions: {} as Record<string, number>,
isOpen: false,
lang: 'en',
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: readPhonePreferences(),
preferences: structuredClone(DEFAULT_PHONE_PREFERENCES),
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
}),
getters: {
@@ -430,8 +464,39 @@ export const usePhoneStore = defineStore('phone', {
open(payload: PhoneOpenPayload = {}): void {
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
this.isOpen = true
},
hydrateDevice(device: PhoneDevice): void {
this.device = device
this.deviceRevisions = Object.fromEntries(
Object.entries(device.data).map(([key, value]) => [
key,
value?.revision ?? 0,
]),
)
this.preferences = parsePhonePreferences(
JSON.stringify(device.data.settings?.payload ?? null),
)
},
saveDeviceNamespace(namespace: string, payload: unknown): void {
const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
const queued = previous.then(async () => {
const response = await nuiCall<{ revision: number }>('device:save', {
namespace,
payload,
revision: this.deviceRevisions[namespace] ?? 0,
})
if (response.success && response.data) {
this.deviceRevisions[namespace] = response.data.revision
}
})
const tracked = queued.finally(() => {
if (namespaceQueues.get(namespace) === tracked)
namespaceQueues.delete(namespace)
})
namespaceQueues.set(namespace, tracked)
},
setCurrentPage(page: number): void {
this.currentPage = clampPage(page)
},
@@ -444,21 +509,21 @@ export const usePhoneStore = defineStore('phone', {
value: boolean,
): void {
this.preferences.settings.notifications[appId][key] = value
writePhonePreferences(this.preferences)
this.saveDeviceNamespace('settings', this.preferences)
},
setPreference<K extends keyof PhonePreferencesV1['settings']>(
key: K,
value: PhonePreferencesV1['settings'][K],
): void {
this.preferences.settings[key] = value
writePhonePreferences(this.preferences)
this.saveDeviceNamespace('settings', this.preferences)
},
setSystemDarkMode(value: boolean): void {
this.systemDarkMode = value
},
setWallpaper(wallpaper: WallpaperId): void {
this.preferences.settings.wallpaper = wallpaper
writePhonePreferences(this.preferences)
this.saveDeviceNamespace('settings', this.preferences)
},
t(path: string, replacements: Record<string, string> = {}): string {
const translated = getByPath(this.locales, path)
+33
View File
@@ -0,0 +1,33 @@
import type { Note } from '@/utils/notes'
export type DeviceDataEntry<T = unknown> = {
payload: T
revision: number
}
export type PhoneDevice = {
data: Record<string, DeviceDataEntry | undefined>
imei: string
name: string
}
export type AccountDevice = {
created_at: string
current: boolean
device_name: string
imei: string
updated_at: string
}
export type IfruitAccount = {
devices: AccountDevice[]
email: string
id?: number
}
export type DeviceBootstrap = {
account: IfruitAccount | null
device: PhoneDevice
notes: Note[]
token: string
}
+4 -18
View File
@@ -1,5 +1,3 @@
export const ALARMS_STORAGE_KEY = 'sky_phone.clock.alarms.v1'
export const ALARM_SOUND_IDS = [
'radar',
'beacon',
@@ -27,7 +25,7 @@ export type Alarm = {
export type AlarmDraft = Pick<Alarm, 'note' | 'sound' | 'time' | 'weekdays'>
const DEFAULT_ALARMS: Alarm[] = [
export const DEFAULT_ALARMS: Alarm[] = [
{
enabled: true,
id: 'weekday',
@@ -90,21 +88,9 @@ function readAlarm(value: unknown): Alarm | null {
}
}
export function readAlarms(): Alarm[] {
const raw = window.localStorage.getItem(ALARMS_STORAGE_KEY)
if (!raw) return structuredClone(DEFAULT_ALARMS)
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return structuredClone(DEFAULT_ALARMS)
return parsed.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm)
} catch {
return structuredClone(DEFAULT_ALARMS)
}
}
export function writeAlarms(alarms: Alarm[]): void {
window.localStorage.setItem(ALARMS_STORAGE_KEY, JSON.stringify(alarms))
export function parseAlarms(value: unknown): Alarm[] {
if (!Array.isArray(value)) return structuredClone(DEFAULT_ALARMS)
return value.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm)
}
export function alarmMinuteKey(date: Date): string {
+1 -1
View File
@@ -14,7 +14,7 @@ describe('notes persistence', () => {
}
expect(parseNotes(JSON.stringify([valid, { id: 'broken' }]))).toEqual([
valid,
{ ...valid, revision: 1 },
])
})
+7 -11
View File
@@ -1,10 +1,9 @@
export const NOTES_STORAGE_KEY = 'sky_phone.notes.v1'
export type Note = {
body: string
createdAt: number
id: string
pinned: boolean
revision: number
title: string
updatedAt: number
}
@@ -21,6 +20,8 @@ function isNote(value: unknown): value is Note {
typeof note.id === 'string' &&
Boolean(note.id) &&
typeof note.pinned === 'boolean' &&
(note.revision === undefined ||
(typeof note.revision === 'number' && Number.isFinite(note.revision))) &&
typeof note.title === 'string' &&
typeof note.updatedAt === 'number' &&
Number.isFinite(note.updatedAt)
@@ -33,16 +34,11 @@ export function parseNotes(raw: string | null): Note[] {
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter(isNote)
return parsed.filter(isNote).map((note) => ({
...note,
revision: note.revision ?? 1,
}))
} catch {
return []
}
}
export function readNotes(): Note[] {
return parseNotes(window.localStorage.getItem(NOTES_STORAGE_KEY))
}
export function writeNotes(notes: Note[]): void {
window.localStorage.setItem(NOTES_STORAGE_KEY, JSON.stringify(notes))
}
-15
View File
@@ -1,7 +1,5 @@
import type { PhoneAppId } from '@/types/apps'
export const PHONE_PREFERENCES_KEY = 'sky_phone.preferences.v1'
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
export const PHONE_FRAME_IDS = [
'black',
@@ -197,16 +195,3 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
return structuredClone(DEFAULT_PHONE_PREFERENCES)
}
}
export function readPhonePreferences(): PhonePreferencesV1 {
return parsePhonePreferences(
window.localStorage.getItem(PHONE_PREFERENCES_KEY),
)
}
export function writePhonePreferences(preferences: PhonePreferencesV1): void {
window.localStorage.setItem(
PHONE_PREFERENCES_KEY,
JSON.stringify(preferences),
)
}
+1 -3
View File
@@ -361,9 +361,7 @@ onBeforeUnmount(() => {
if (draftTimer) clearTimeout(draftTimer)
if (searchTimer) clearTimeout(searchTimer)
if (screen.value === 'compose') {
void saveDraftNow().finally(() => mail.logout())
} else {
void mail.logout()
void saveDraftNow()
}
})
</script>
+248 -21
View File
@@ -1,13 +1,20 @@
<script setup lang="ts">
import {
kBlock,
kBlockTitle,
kButton,
kDialog,
kDialogButton,
kList,
kListButton,
kListInput,
kListItem,
kNavbar,
kNavbarBackLink,
kPage,
kRange,
kSearchbar,
kToast,
kToggle,
} from 'konsta/vue'
import {
@@ -16,7 +23,9 @@ import {
EyeOff,
Monitor,
Plane,
RotateCcw,
Settings,
Smartphone,
Sun,
UserRound,
Volume2,
@@ -26,6 +35,7 @@ import { computed, nextTick, ref, type ComponentPublicInstance } from 'vue'
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { PHONE_APPS } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import { useAccountStore } from '@/stores/account'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
import {
APPEARANCE_MODE_IDS,
@@ -55,10 +65,21 @@ type RootToggleKey = 'airplaneMode' | 'streamerMode'
type SubmenuView = Exclude<SettingsView, 'root' | 'notification-detail'>
const phone = usePhoneStore()
const account = useAccountStore()
const query = ref('')
const activeView = ref<SettingsView>('root')
const selectedNotificationAppId = ref<PhoneAppId>('calculator')
const settingsPage = ref<ComponentPublicInstance | null>(null)
const accountMode = ref<'login' | 'register'>('login')
const accountEmail = ref('')
const accountPassword = ref('')
const accountConfirm = ref('')
const accountSubmitting = ref(false)
const accountToast = ref('')
const removeDeviceImei = ref('')
const removeDevicePassword = ref('')
const removeDeviceOpened = ref(false)
const resetOpened = ref(false)
const toggleRows = [
{
@@ -208,6 +229,70 @@ function selectRingtone(ringtone: RingtoneId): void {
function selectNotificationSound(sound: NotificationSoundId): void {
phone.setPreference('notificationSound', sound)
}
function eventValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
function accountError(error?: string): string {
const known = [
'invalid_email',
'invalid_password',
'invalid_credentials',
'email_taken',
'rate_limited',
'current_device',
'device_not_found',
]
return phone.t(
`Apps.settings.accountErrors.${error && known.includes(error) ? error : 'default'}`,
)
}
async function submitAccount(): Promise<void> {
if (
accountMode.value === 'register' &&
accountPassword.value !== accountConfirm.value
) {
accountToast.value = phone.t('Apps.mail.passwordsMismatch')
return
}
accountSubmitting.value = true
const response =
accountMode.value === 'login'
? await account.login(accountEmail.value, accountPassword.value)
: await account.register(accountEmail.value, accountPassword.value)
accountSubmitting.value = false
if (!response.success) accountToast.value = accountError(response.error)
else {
accountPassword.value = ''
accountConfirm.value = ''
}
}
async function logoutAccount(): Promise<void> {
if (!(await account.logout())) accountToast.value = accountError()
}
function requestRemoveDevice(imei: string): void {
removeDeviceImei.value = imei
removeDevicePassword.value = ''
removeDeviceOpened.value = true
}
async function confirmRemoveDevice(): Promise<void> {
const response = await account.removeDevice(
removeDeviceImei.value,
removeDevicePassword.value,
)
if (!response.success) accountToast.value = accountError(response.error)
else removeDeviceOpened.value = false
}
async function confirmFactoryReset(): Promise<void> {
resetOpened.value = false
if (!(await account.factoryReset())) accountToast.value = accountError()
}
</script>
<template>
@@ -331,34 +416,129 @@ function selectNotificationSound(sound: NotificationSoundId): void {
</k-navbar>
<template v-if="activeView === 'account'">
<k-list strong inset>
<template v-if="!account.email">
<k-block-title>
{{
phone.t(
accountMode === 'login'
? 'Apps.settings.accountLoginBody'
: 'Apps.mail.passwordWarning',
)
}}
</k-block-title>
<k-list>
<k-list-input
:value="accountEmail"
:label="phone.t('Apps.mail.email')"
outline
floating-label
autocomplete="username"
autocapitalize="none"
@input="accountEmail = eventValue($event)"
/>
<k-list-input
type="password"
:value="accountPassword"
:label="phone.t('Apps.mail.password')"
outline
floating-label
@input="accountPassword = eventValue($event)"
/>
<k-list-input
v-if="accountMode === 'register'"
type="password"
:value="accountConfirm"
:label="phone.t('Apps.mail.confirmPassword')"
outline
floating-label
@input="accountConfirm = eventValue($event)"
/>
</k-list>
<k-block>
<k-button large rounded :disabled="accountSubmitting" @click="submitAccount">
{{
phone.t(
accountMode === 'login'
? 'Apps.mail.login'
: 'Apps.mail.register',
)
}}
</k-button>
</k-block>
<k-list strong inset>
<k-list-button
@click="accountMode = accountMode === 'login' ? 'register' : 'login'"
>
{{
phone.t(
accountMode === 'login'
? 'Apps.mail.register'
: 'Apps.mail.login',
)
}}
</k-list-button>
</k-list>
<k-block-title>{{ phone.t('Apps.settings.deviceInformation') }}</k-block-title>
<k-list strong inset>
<k-list-item
:title="phone.t('Apps.settings.imei')"
:after="phone.device?.imei ?? '—'"
/>
<k-list-button @click="resetOpened = true">
<RotateCcw :size="18" />
{{ phone.t('Apps.settings.factoryReset') }}
</k-list-button>
</k-list>
</template>
<template v-else>
<k-list strong inset>
<k-list-item
:title="phone.t('Apps.settings.accountName')"
:subtitle="phone.t('Apps.settings.accountLocalDetail')"
:title="account.email"
:subtitle="phone.t('Apps.settings.accountCloudDetail')"
>
<template #media>
<UserRound class="w-12 h-12 text-primary" />
</template>
</k-list-item>
</k-list>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.accountInformation') }}
</k-block-title>
<k-list strong inset>
<k-list-item
:title="phone.t('Apps.settings.accountStatus')"
:after="phone.t('Apps.settings.accountStatusValue')"
/>
<k-list-item
:title="phone.t('Apps.settings.accountStorage')"
:after="phone.t('Apps.settings.accountStorageValue')"
/>
<k-list-item
:title="phone.t('Apps.settings.accountPurchases')"
:after="phone.t('Apps.settings.accountPurchasesValue')"
/>
</k-list>
<k-block-title>{{ phone.t('Apps.settings.deviceInformation') }}</k-block-title>
<k-list strong inset>
<k-list-item
:title="phone.t('Apps.settings.imei')"
:after="phone.device?.imei ?? '—'"
/>
</k-list>
<k-block-title>{{ phone.t('Apps.settings.linkedDevices') }}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="device in account.devices"
:key="device.imei"
:title="device.device_name"
:subtitle="device.imei"
:after="device.current ? phone.t('Apps.settings.thisDevice') : undefined"
>
<template #media><Smartphone :size="22" /></template>
<template v-if="!device.current" #footer>
<k-list-button @click="requestRemoveDevice(device.imei)">
{{ phone.t('Apps.settings.removeDevice') }}
</k-list-button>
</template>
</k-list-item>
</k-list>
<k-list strong inset>
<k-list-button @click="logoutAccount">
{{ phone.t('Apps.settings.signOut') }}
</k-list-button>
<k-list-button @click="resetOpened = true">
<RotateCcw :size="18" />
{{ phone.t('Apps.settings.factoryReset') }}
</k-list-button>
</k-list>
</template>
</template>
<template v-else-if="activeView === 'notifications'">
@@ -689,4 +869,51 @@ function selectNotificationSound(sound: NotificationSoundId): void {
</template>
</template>
</k-page>
<k-dialog
:opened="removeDeviceOpened"
:title="phone.t('Apps.settings.removeDevice')"
:content="phone.t('Apps.settings.removeDeviceBody')"
@backdropclick="removeDeviceOpened = false"
>
<k-list>
<k-list-input
type="password"
:value="removeDevicePassword"
:label="phone.t('Apps.mail.password')"
@input="removeDevicePassword = eventValue($event)"
/>
</k-list>
<template #buttons>
<k-dialog-button @click="removeDeviceOpened = false">
{{ phone.t('Common.cancel') }}
</k-dialog-button>
<k-dialog-button strong @click="confirmRemoveDevice">
{{ phone.t('Apps.settings.removeDevice') }}
</k-dialog-button>
</template>
</k-dialog>
<k-dialog
:opened="resetOpened"
:title="phone.t('Apps.settings.factoryReset')"
:content="phone.t('Apps.settings.factoryResetBody')"
@backdropclick="resetOpened = false"
>
<template #buttons>
<k-dialog-button @click="resetOpened = false">
{{ phone.t('Common.cancel') }}
</k-dialog-button>
<k-dialog-button strong @click="confirmFactoryReset">
{{ phone.t('Common.reset') }}
</k-dialog-button>
</template>
</k-dialog>
<k-toast
:opened="Boolean(accountToast)"
position="center"
:text="accountToast"
@click="accountToast = ''"
/>
</template>
+86 -4
View File
@@ -9,6 +9,18 @@ app.use(express.json())
let authenticated = false
let draft = null
let linkedAccount = null
let mockNotes = []
const deviceData = {}
const accountDevices = [
{
created_at: '2026-08-04 12:00:00',
current: true,
device_name: 'iFruit Phone',
imei: '356938035643809',
updated_at: '2026-08-04 12:00:00',
},
]
const messages = [
{
body: 'Welcome to iFruit Mail. Your shared mailbox is ready to use.',
@@ -82,12 +94,82 @@ function counts() {
app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
if (endpoint === 'account:login' || endpoint === 'account:register') {
authenticated = true
linkedAccount = {
devices: accountDevices,
email: request.body.email.includes('@')
? request.body.email
: `${request.body.email}@ifruit.com`,
}
response.json({ success: true, data: linkedAccount })
return
}
if (endpoint === 'account:logout') {
authenticated = false
linkedAccount = null
response.json({ success: true })
return
}
if (endpoint === 'account:devices') {
response.json({ success: true, data: accountDevices })
return
}
if (endpoint === 'account:remove-device') {
response.json({ success: true, data: accountDevices })
return
}
if (endpoint === 'device:save') {
const current = deviceData[request.body.namespace]
const revision = (current?.revision ?? 0) + 1
deviceData[request.body.namespace] = {
payload: request.body.payload,
revision,
}
response.json({ success: true, data: { revision } })
return
}
if (endpoint === 'device:factory-reset') {
authenticated = false
linkedAccount = null
mockNotes = []
for (const key of Object.keys(deviceData)) delete deviceData[key]
response.json({ success: true })
return
}
if (endpoint === 'notes:list') {
response.json({ success: true, data: mockNotes })
return
}
if (endpoint === 'notes:create') {
mockNotes.unshift({ ...request.body, revision: 1 })
response.json({ success: true, data: mockNotes })
return
}
if (endpoint === 'notes:update') {
const index = mockNotes.findIndex((note) => note.id === request.body.id)
if (index >= 0) {
mockNotes[index] = {
...request.body,
revision: mockNotes[index].revision + 1,
updatedAt: Date.now(),
}
}
response.json({ success: true, data: mockNotes })
return
}
if (endpoint === 'notes:delete') {
mockNotes = mockNotes.filter((note) => note.id !== request.body.id)
response.json({ success: true, data: mockNotes })
return
}
if (endpoint === 'mail:login' || endpoint === 'mail:register') {
authenticated = true
response.json({
success: true,
data: { counts: counts(), email: 'demo@ifruit.com' },
})
linkedAccount = {
devices: accountDevices,
email: 'demo@ifruit.com',
}
response.json({ success: true, data: linkedAccount })
return
}
if (endpoint === 'mail:logout') {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-BW7SWjn6.js"></script>
<script type="module" crossorigin src="./assets/sky-index-CN9A-zsO.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-CFphfAe0.css">
</head>
<body>