From 8090dbd5abd97dcb634670c39ff7c321e0c68c76 Mon Sep 17 00:00:00 2001 From: Eichenholz Date: Tue, 4 Aug 2026 11:41:56 +0200 Subject: [PATCH] ADD - notes app --- frontend/src/assets/img/app-icons/notes.webp | Bin 0 -> 4162 bytes frontend/src/config/apps.ts | 21 +- frontend/src/stores/notes.ts | 48 +++ frontend/src/stores/phone.ts | 24 ++ frontend/src/types/apps.ts | 1 + frontend/src/utils/notes.test.ts | 26 ++ frontend/src/utils/notes.ts | 48 +++ frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/NotesApp.vue | 305 +++++++++++++++++++ sky_phone/config/locales/en.lua | 9 + sky_phone/source/html/index.html | 4 +- 11 files changed, 482 insertions(+), 5 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/notes.webp create mode 100644 frontend/src/stores/notes.ts create mode 100644 frontend/src/utils/notes.test.ts create mode 100644 frontend/src/utils/notes.ts create mode 100644 frontend/src/views/apps/NotesApp.vue 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 0000000000000000000000000000000000000000..959a61aef1257a31ecd0217b74ddf00031d6b3da GIT binary patch literal 4162 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 M?!v4!|D3=80O6@Jh5!Hn literal 0 HcmV?d00001 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 - - + +