diff --git a/frontend/src/assets/img/app-icons/notes.webp b/frontend/src/assets/img/app-icons/notes.webp new file mode 100644 index 0000000..959a61a Binary files /dev/null and b/frontend/src/assets/img/app-icons/notes.webp differ diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 68ad739..a9469bb 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -3,6 +3,7 @@ import { Camera, Clock3, Images, + NotebookPen, Settings, ShoppingBag, } from 'lucide-vue-next' @@ -12,11 +13,25 @@ import appStoreIcon from '@/assets/img/app-icons/apps.webp' import calculatorIcon from '@/assets/img/app-icons/calculator.webp' import cameraIcon from '@/assets/img/app-icons/camera.webp' import clockIcon from '@/assets/img/app-icons/clock.webp' +import notesIcon from '@/assets/img/app-icons/notes.webp' import photosIcon from '@/assets/img/app-icons/gallery.webp' import settingsIcon from '@/assets/img/app-icons/settings.webp' import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')), + ), + dockOrder: null, + gridOrder: 3, + icon: markRaw(NotebookPen), + iconClass: '', + iconImage: notesIcon, + id: 'notes', + labelKey: 'Apps.notes.name', + route: '/apps/notes', + }, { component: markRaw( defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')), @@ -61,7 +76,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ defineAsyncComponent(() => import('@/views/apps/PhotosApp.vue')), ), dockOrder: null, - gridOrder: 3, + gridOrder: 4, icon: markRaw(Images), iconClass: 'app-icon--photos', iconImage: photosIcon, @@ -74,7 +89,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')), ), dockOrder: 0, - gridOrder: 4, + gridOrder: 5, icon: markRaw(ShoppingBag), iconClass: 'app-icon--store', iconImage: appStoreIcon, @@ -87,7 +102,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')), ), dockOrder: null, - gridOrder: 5, + gridOrder: 6, icon: markRaw(Settings), iconClass: 'app-icon--settings', iconImage: settingsIcon, diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts new file mode 100644 index 0000000..a841cd3 --- /dev/null +++ b/frontend/src/stores/notes.ts @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia' + +import { + type Note, + type NoteDraft, + readNotes, + writeNotes, +} from '@/utils/notes' + +export const useNotesStore = defineStore('notes', { + state: () => ({ + notes: readNotes(), + }), + actions: { + createNote(draft: NoteDraft): Note { + const now = Date.now() + const note: Note = { + ...draft, + createdAt: now, + id: `note-${now}-${Math.random().toString(36).slice(2, 9)}`, + pinned: false, + updatedAt: now, + } + this.notes.unshift(note) + this.persist() + return note + }, + deleteNote(id: string): void { + this.notes = this.notes.filter((note) => note.id !== id) + this.persist() + }, + persist(): void { + writeNotes(this.notes) + }, + togglePinned(id: string): void { + const note = this.notes.find((candidate) => candidate.id === id) + if (!note) return + note.pinned = !note.pinned + this.persist() + }, + 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() + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 4673598..fe34e67 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -136,6 +136,30 @@ const defaultLocales: LocaleTree = { ringing: 'Timer', }, }, + notes: { + name: 'Notes', + note: 'Note', + back: 'Back', + actions: 'Note actions', + newNote: 'New Note', + searchPlaceholder: 'Search Notes', + title: 'Title', + titlePlaceholder: 'Note title', + body: 'Note', + bodyPlaceholder: 'Start writing...', + untitled: 'Untitled', + noText: 'No additional text', + emptyBadge: 'ON DEVICE', + emptyTitle: 'No Notes', + emptyBody: 'Create a note to keep important details close at hand.', + noResults: 'No Results', + noResultsBody: 'Try searching for a different word or phrase.', + pin: 'Pin note', + unpin: 'Unpin note', + deleteNote: 'Delete note', + deleteTitle: 'Delete Note?', + deleteBody: 'This note will be permanently deleted.', + }, photos: { name: 'Photos', searchPlaceholder: 'Photos, people, places...', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 7f58c91..377fd57 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -4,6 +4,7 @@ export type PhoneAppId = | 'calculator' | 'camera' | 'clock' + | 'notes' | 'photos' | 'app-store' | 'settings' diff --git a/frontend/src/utils/notes.test.ts b/frontend/src/utils/notes.test.ts new file mode 100644 index 0000000..c6fb610 --- /dev/null +++ b/frontend/src/utils/notes.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { parseNotes } from './notes' + +describe('notes persistence', () => { + it('returns valid notes and ignores malformed entries', () => { + const valid = { + body: 'Meet at Mission Row.', + createdAt: 10, + id: 'note-1', + pinned: true, + title: 'Shift briefing', + updatedAt: 20, + } + + expect(parseNotes(JSON.stringify([valid, { id: 'broken' }]))).toEqual([ + valid, + ]) + }) + + it('recovers from missing or invalid storage', () => { + expect(parseNotes(null)).toEqual([]) + expect(parseNotes('{invalid')).toEqual([]) + expect(parseNotes('{}')).toEqual([]) + }) +}) diff --git a/frontend/src/utils/notes.ts b/frontend/src/utils/notes.ts new file mode 100644 index 0000000..e748481 --- /dev/null +++ b/frontend/src/utils/notes.ts @@ -0,0 +1,48 @@ +export const NOTES_STORAGE_KEY = 'sky_phone.notes.v1' + +export type Note = { + body: string + createdAt: number + id: string + pinned: boolean + title: string + updatedAt: number +} + +export type NoteDraft = Pick + +function isNote(value: unknown): value is Note { + if (!value || typeof value !== 'object') return false + const note = value as Partial + return ( + typeof note.body === 'string' && + typeof note.createdAt === 'number' && + Number.isFinite(note.createdAt) && + typeof note.id === 'string' && + Boolean(note.id) && + typeof note.pinned === 'boolean' && + typeof note.title === 'string' && + typeof note.updatedAt === 'number' && + Number.isFinite(note.updatedAt) + ) +} + +export function parseNotes(raw: string | null): Note[] { + if (!raw) return [] + + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter(isNote) + } 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)) +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 1cedb76..e929633 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -50,6 +50,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< calculator: { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, + notes: { enabled: true, sounds: true }, photos: { enabled: true, sounds: true }, settings: { enabled: true, sounds: true }, } diff --git a/frontend/src/views/apps/NotesApp.vue b/frontend/src/views/apps/NotesApp.vue new file mode 100644 index 0000000..8e382e2 --- /dev/null +++ b/frontend/src/views/apps/NotesApp.vue @@ -0,0 +1,305 @@ + + + diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 07373ea..ff00ee2 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -42,6 +42,15 @@ Locales["en"] = { note = "Note", notePlaceholder = "Timer", sound = "Sound", ringing = "Timer", }, }, + notes = { + name = "Notes", note = "Note", back = "Back", actions = "Note actions", + newNote = "New Note", searchPlaceholder = "Search Notes", + title = "Title", titlePlaceholder = "Note title", body = "Note", bodyPlaceholder = "Start writing...", + untitled = "Untitled", noText = "No additional text", emptyBadge = "ON DEVICE", emptyTitle = "No Notes", + emptyBody = "Create a note to keep important details close at hand.", noResults = "No Results", + noResultsBody = "Try searching for a different word or phrase.", pin = "Pin note", unpin = "Unpin note", + deleteNote = "Delete note", deleteTitle = "Delete Note?", deleteBody = "This note will be permanently deleted.", + }, photos = { name = "Photos", searchPlaceholder = "Photos, people, places...", recents = "Recents", favorites = "Favorites", items = "items", memories = "Memories", featured = "City colors", diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index f7fc87a..801b788 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,8 +4,8 @@ Sky Phone - - + +