From 9bc90b8319a7559249c906f08a47632182d7ba00 Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Fri, 27 Mar 2026 07:44:48 +0100 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20keyboard/edit=20UX=20=E2=80=94=20?= =?UTF-8?q?fix=20keyboard=20coverage,=20multiline=20title,=20export,=20edi?= =?UTF-8?q?t-by-default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap note editor in KeyboardAvoidingView so keyboard never covers text (#1, #8) - Set isEditing=true by default without auto-focusing textarea (#9) - Make title TextInput multiline with lineHeight:34 so long titles wrap (#8) - Add native Share sheet via React Native Share API — share button in view mode (#7) - Add '+ Add item' row to ActionItemsList with inline TextInput for action_items format (#4) Co-Authored-By: Claude Sonnet 4.6 --- app/note/[id].tsx | 169 ++++++++++++++++++++++++++++++++++++++++++---- types/index.ts | 9 +++ 2 files changed, 166 insertions(+), 12 deletions(-) diff --git a/app/note/[id].tsx b/app/note/[id].tsx index 70ce604..60c8508 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, useRef } from 'react'; +import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { View, Text, @@ -6,6 +6,9 @@ import { StyleSheet, Pressable, ScrollView, + KeyboardAvoidingView, + Platform, + Share, ActivityIndicator, } from 'react-native'; import Markdown from 'react-native-markdown-display'; @@ -27,7 +30,18 @@ import { AudioPlaybackBar } from '../../components/AudioPlaybackBar'; // Parses action_items markdown into a structured list with tappable checkboxes. // Toggling a checkbox updates the underlying markdown text and persists via onTextChange. -function ActionItemsList({ text, onTextChange }: { text: string; onTextChange?: (updated: string) => void }) { +// When allowAdd is true, shows an "+ Add item" button at the bottom. +function ActionItemsList({ + text, + onTextChange, + allowAdd = false, +}: { + text: string; + onTextChange?: (updated: string) => void; + allowAdd?: boolean; +}) { + const [newItemText, setNewItemText] = useState(''); + const [showInput, setShowInput] = useState(false); const lines = text.split('\n'); const toggle = (lineIndex: number) => { @@ -40,6 +54,16 @@ function ActionItemsList({ text, onTextChange }: { text: string; onTextChange?: onTextChange?.(updated); }; + const addItem = () => { + const trimmed = newItemText.trim(); + if (!trimmed) { setShowInput(false); return; } + const newLine = `- [ ] ${trimmed}`; + const updated = text.trimEnd() + '\n' + newLine; + onTextChange?.(updated); + setNewItemText(''); + setShowInput(false); + }; + return ( {lines.map((line, i) => { @@ -62,6 +86,33 @@ function ActionItemsList({ text, onTextChange }: { text: string; onTextChange?: if (!line.trim()) return null; return {line}; })} + {allowAdd && ( + showInput ? ( + + + + Add + + { setShowInput(false); setNewItemText(''); }} style={actionStyles.addCancel}> + + + + ) : ( + setShowInput(true)}> + + Add item + + ) + )} ); } @@ -105,6 +156,53 @@ const actionStyles = StyleSheet.create({ color: Colors.textTertiary, textDecorationLine: 'line-through', }, + addButton: { + marginTop: 12, + paddingVertical: 8, + paddingHorizontal: 4, + alignSelf: 'flex-start', + }, + addButtonText: { + fontSize: 15, + color: Colors.primary, + fontWeight: '600', + }, + addRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 12, + gap: 8, + }, + addInput: { + flex: 1, + borderWidth: 1.5, + borderColor: Colors.primary, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 15, + color: Colors.text, + backgroundColor: Colors.surface, + }, + addConfirm: { + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: Colors.primary, + borderRadius: 10, + }, + addConfirmText: { + color: '#fff', + fontWeight: '700', + fontSize: 14, + }, + addCancel: { + paddingHorizontal: 8, + paddingVertical: 8, + }, + addCancelText: { + color: Colors.textTertiary, + fontSize: 16, + }, }); export default function NoteDetailScreen() { @@ -117,23 +215,41 @@ export default function NoteDetailScreen() { const note = useMemo(() => notes.find((n) => n.id === id), [notes, id]); - const [isEditing, setIsEditing] = useState(false); + // Edit mode is active by default but we do NOT auto-focus the keyboard (#9) + const [isEditing, setIsEditing] = useState(true); const [editTitle, setEditTitle] = useState(note?.title ?? ''); const [editText, setEditText] = useState(note?.formatted_text ?? ''); const [editFormatType, setEditFormatType] = useState(note?.format_type ?? 'paragraph'); const [saving, setSaving] = useState(false); + const scrollRef = useRef(null); const titleInputRef = useRef(null); + const contentInputRef = useRef(null); const [showFormatPicker, setShowFormatPicker] = useState(false); const [selectedFormat, setSelectedFormat] = useState(null); const [reformatInstructions, setReformatInstructions] = useState(''); const [reformatting, setReformatting] = useState(false); + // Sync edit state when note loads (handles navigation to a different note) useEffect(() => { - if (isEditing) { - setTimeout(() => titleInputRef.current?.focus(), 50); + if (note) { + setEditTitle(note.title); + setEditText(note.formatted_text); + setEditFormatType(note.format_type); + } + }, [note?.id]); + + const handleShare = useCallback(async () => { + if (!note) return; + try { + await Share.share({ + title: note.title, + message: `${note.title}\n\n${note.formatted_text}`, + }); + } catch { + // User dismissed share sheet — not an error } - }, [isEditing]); + }, [note]); const [noteTags, setNoteTags] = useState([]); const [tagPickerVisible, setTagPickerVisible] = useState(false); @@ -217,6 +333,9 @@ export default function NoteDetailScreen() { setShowFormatPicker(false); setReformatInstructions(''); setSelectedFormat(null); + // Dismiss keyboard + titleInputRef.current?.blur(); + contentInputRef.current?.blur(); }; const handleReformat = async (formatType: FormatType) => { @@ -272,6 +391,11 @@ export default function NoteDetailScreen() { return ( + router.back()} @@ -322,12 +446,16 @@ export default function NoteDetailScreen() { ) : ( <> { - setEditTitle(note.title); - setEditText(note.formatted_text); - setEditFormatType(note.format_type); - setIsEditing(true); - }} + onPress={handleShare} + style={({ pressed }) => [ + styles.actionButton, + pressed && { opacity: 0.6 }, + ]} + > + Share + + setIsEditing(true)} style={({ pressed }) => [ styles.actionButton, pressed && { opacity: 0.6 }, @@ -359,8 +487,10 @@ export default function NoteDetailScreen() { @@ -380,8 +510,11 @@ export default function NoteDetailScreen() { placeholderTextColor={Colors.textTertiary} selectTextOnFocus returnKeyType="next" + multiline + onSubmitEditing={() => contentInputRef.current?.focus()} /> {/* Inline format picker */} @@ -447,6 +581,7 @@ export default function NoteDetailScreen() { updateNote(note.id, { formatted_text: updated })} + allowAdd /> ) : ( {note.formatted_text} @@ -501,6 +636,7 @@ export default function NoteDetailScreen() { onToggle={handleToggleTag} onCreateTag={handleCreateTag} /> + ); } @@ -566,6 +702,9 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: Colors.background, }, + keyboardAvoid: { + flex: 1, + }, centered: { flex: 1, justifyContent: 'center', @@ -633,6 +772,11 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '700', }, + shareText: { + color: Colors.primary, + fontSize: 16, + fontWeight: '500', + }, archiveText: { color: Colors.warning, fontSize: 16, @@ -678,6 +822,7 @@ const styles = StyleSheet.create({ borderBottomColor: Colors.primary, paddingBottom: 10, letterSpacing: -0.3, + lineHeight: 34, }, textInput: { fontSize: 16, diff --git a/types/index.ts b/types/index.ts index aad2d47..a76b905 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,5 +1,7 @@ export type FormatType = 'bullet_list' | 'paragraph' | 'action_items' | 'meeting_notes' | 'custom'; +export type NoteSort = 'date_desc' | 'date_asc' | 'title_asc' | 'title_desc'; + export type RecordingStatus = | 'pending' // audio saved locally, not yet transcribed | 'transcribing' // transcription API call in progress @@ -8,6 +10,11 @@ export type RecordingStatus = | 'formatted' // formattedTitle + formattedText populated | 'saved'; // note created in Supabase, noteId populated +export interface UncertainTerm { + original: string; + suggestion: string | null; +} + export interface Recording { id: string; // uuid v4, generated on device localUri: string; // absolute path in documentDirectory/recordings/ @@ -16,6 +23,7 @@ export interface Recording { createdAt: string; // ISO 8601 timestamp status: RecordingStatus; rawTranscription?: string; // set after Transcribe step + uncertainTerms?: UncertainTerm[]; // set after Transcribe step (acronym validation) formattedTitle?: string; // set after Format step formattedText?: string; // set after Format step formatType?: FormatType; // set when Format is triggered @@ -47,6 +55,7 @@ export interface UserPreferences { custom_example: string | null; custom_instructions: string | null; autotagging_enabled: boolean; + default_tag_id: string | null; updated_at: string; is_admin?: boolean; } From 819fa8f47db69131ae745e46cb0b3ba31c81b89e Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Fri, 27 Mar 2026 07:44:54 +0100 Subject: [PATCH 02/17] feat: sort notes + default tag preference - Add NoteSort type (date_desc/asc, title_asc/desc); server-side ordering in fetchNotes (#5) - Sort picker bottom-sheet modal in home screen header with active checkmark (#5) - Store defaultTagId in user_preferences via Supabase upsert (migration 019) (#6) - PreferencesContext exposes defaultTagId/setDefaultTagId; home screen applies it on mount (#6) - setDefaultTagId service function for Supabase persistence (#6) Co-Authored-By: Claude Sonnet 4.6 --- app/index.tsx | 123 +++++++++++++++++++++++- contexts/NotesContext.tsx | 17 +++- contexts/PreferencesContext.tsx | 18 +++- services/notes.ts | 26 ++++- services/preferences.ts | 16 +++ supabase/migrations/019_default_tag.sql | 3 + 6 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 supabase/migrations/019_default_tag.sql diff --git a/app/index.tsx b/app/index.tsx index 0a76a77..77c1a46 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,27 +1,46 @@ -import React, { useMemo, useState } from 'react'; -import { View, Text, StyleSheet, Pressable, Image } from 'react-native'; +import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import { View, Text, StyleSheet, Pressable, Image, Modal } from 'react-native'; import { useRouter } from 'expo-router'; import { useNotes } from '../contexts/NotesContext'; import { useTags } from '../contexts/TagsContext'; +import { usePreferences } from '../contexts/PreferencesContext'; import { NoteGrid } from '../components/NoteGrid'; import { SearchBar } from '../components/SearchBar'; import { RecordButton } from '../components/RecordButton'; import { TagFilterBar } from '../components/TagFilterBar'; import { Colors } from '../constants/colors'; -import { Note } from '../types'; +import { Note, NoteSort } from '../types'; import { SafeAreaView } from 'react-native-safe-area-context'; import * as tagsService from '../services/tags'; +const SORT_OPTIONS: { value: NoteSort; label: string }[] = [ + { value: 'date_desc', label: 'Newest first' }, + { value: 'date_asc', label: 'Oldest first' }, + { value: 'title_asc', label: 'Title A→Z' }, + { value: 'title_desc', label: 'Title Z→A' }, +]; + export default function HomeScreen() { const router = useRouter(); - const { filteredNotes, loading, searchQuery, setSearchQuery, fetchNotes } = + const { filteredNotes, loading, searchQuery, setSearchQuery, fetchNotes, sort, setSort } = useNotes(); const { tags, refreshNoteTagsMap } = useTags(); + const { defaultTagId } = usePreferences(); const [selectedTagId, setSelectedTagId] = useState(null); const [tagNoteIds, setTagNoteIds] = useState(null); + const [showSortPicker, setShowSortPicker] = useState(false); + + // Apply default tag filter on first load + useEffect(() => { + if (defaultTagId && tags.length > 0) { + handleTagSelect(defaultTagId); + } + // Only run once when tags become available + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultTagId, tags.length > 0]); - const handleTagSelect = async (tagId: string | null) => { + const handleTagSelect = useCallback(async (tagId: string | null) => { setSelectedTagId(tagId); if (tagId === null) { setTagNoteIds(null); @@ -33,6 +52,11 @@ export default function HomeScreen() { setTagNoteIds([]); } } + }, []); + + const handleSortChange = (newSort: NoteSort) => { + setSort(newSort); + setShowSortPicker(false); }; const displayedNotes = useMemo(() => { @@ -64,6 +88,8 @@ export default function HomeScreen() { router.push('/recordings'); }; + const currentSortLabel = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? 'Sort'; + return ( @@ -79,6 +105,16 @@ export default function HomeScreen() { + setShowSortPicker(true)} + style={({ pressed }) => [ + styles.headerActionButton, + pressed && { opacity: 0.6 }, + ]} + accessibilityLabel="Sort notes" + > + ↕ {currentSortLabel} + [ @@ -125,6 +161,36 @@ export default function HomeScreen() { + + {/* Sort picker modal */} + setShowSortPicker(false)} + > + setShowSortPicker(false)}> + + Sort notes + {SORT_OPTIONS.map((opt) => ( + [ + styles.sortOption, + sort === opt.value && styles.sortOptionActive, + pressed && { opacity: 0.7 }, + ]} + onPress={() => handleSortChange(opt.value)} + > + + {opt.label} + + {sort === opt.value && } + + ))} + + + ); } @@ -175,11 +241,58 @@ const styles = StyleSheet.create({ backgroundColor: Colors.surface, ...Colors.shadow.sm, }, + sortButtonText: { + fontSize: 13, + fontWeight: '600', + color: Colors.textSecondary, + }, recordingsButtonText: { fontSize: 14, fontWeight: '600', color: Colors.primary, }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + sortSheet: { + backgroundColor: Colors.background, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 24, + paddingBottom: 40, + }, + sortSheetTitle: { + fontSize: 16, + fontWeight: '700', + color: Colors.text, + marginBottom: 16, + }, + sortOption: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: 12, + borderRadius: 12, + }, + sortOptionActive: { + backgroundColor: Colors.primarySubtle, + }, + sortOptionText: { + fontSize: 16, + color: Colors.text, + }, + sortOptionTextActive: { + color: Colors.primary, + fontWeight: '600', + }, + sortCheckmark: { + fontSize: 16, + color: Colors.primary, + fontWeight: '700', + }, settingsButton: { width: 44, height: 44, diff --git a/contexts/NotesContext.tsx b/contexts/NotesContext.tsx index 831963b..65fe158 100644 --- a/contexts/NotesContext.tsx +++ b/contexts/NotesContext.tsx @@ -7,7 +7,7 @@ import React, { useMemo, ReactNode, } from 'react'; -import { Note, CreateNoteInput, UpdateNoteInput } from '../types'; +import { Note, CreateNoteInput, UpdateNoteInput, NoteSort } from '../types'; import * as notesService from '../services/notes'; import { useAuth } from './AuthContext'; @@ -15,6 +15,7 @@ interface NotesState { notes: Note[]; loading: boolean; searchQuery: string; + sort: NoteSort; } interface NotesContextType extends NotesState { @@ -29,6 +30,7 @@ interface NotesContextType extends NotesState { restoreNote: (id: string) => Promise; deleteNotePermanently: (id: string) => Promise; setSearchQuery: (query: string) => void; + setSort: (sort: NoteSort) => void; } const NotesContext = createContext(undefined); @@ -41,24 +43,26 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ notes: [], loading: false, searchQuery: '', + sort: 'date_desc', }); const fetchNotes = useCallback(async () => { setState((prev) => ({ ...prev, loading: true })); try { - const notes = await notesService.fetchNotes(); + const notes = await notesService.fetchNotes(state.sort); setState((prev) => ({ ...prev, notes, loading: false })); } catch (error) { console.error('Failed to fetch notes:', error); setState((prev) => ({ ...prev, loading: false })); } - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.sort]); useEffect(() => { if (session) { fetchNotes(); } else { - setState({ notes: [], loading: false, searchQuery: '' }); + setState({ notes: [], loading: false, searchQuery: '', sort: 'date_desc' }); } }, [session, fetchNotes]); @@ -153,6 +157,10 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ setState((prev) => ({ ...prev, searchQuery: query })); }, []); + const setSort = useCallback((sort: NoteSort) => { + setState((prev) => ({ ...prev, sort })); + }, []); + const filteredNotes = useMemo(() => { if (!state.searchQuery.trim()) return state.notes; const q = state.searchQuery.toLowerCase(); @@ -179,6 +187,7 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ restoreNote, deleteNotePermanently, setSearchQuery, + setSort, }} > {children} diff --git a/contexts/PreferencesContext.tsx b/contexts/PreferencesContext.tsx index 5542d98..eba6aca 100644 --- a/contexts/PreferencesContext.tsx +++ b/contexts/PreferencesContext.tsx @@ -16,6 +16,7 @@ interface PreferencesState { customExample: string; customInstructions: string; autotaggingEnabled: boolean; + defaultTagId: string | null; isAdmin: boolean; tier: 'free' | 'unlimited'; loading: boolean; @@ -26,6 +27,7 @@ interface PreferencesContextType extends PreferencesState { setCustomExample: (example: string) => Promise; setCustomInstructions: (instructions: string) => Promise; setAutotaggingEnabled: (enabled: boolean) => Promise; + setDefaultTagId: (tagId: string | null) => Promise; } const PreferencesContext = createContext( @@ -41,6 +43,7 @@ export const PreferencesProvider: React.FC<{ children: ReactNode }> = ({ customExample: '', customInstructions: '', autotaggingEnabled: false, + defaultTagId: null, isAdmin: false, tier: 'free', loading: true, @@ -50,7 +53,7 @@ export const PreferencesProvider: React.FC<{ children: ReactNode }> = ({ if (session) { loadPreferences(); } else { - setState({ defaultFormat: DEFAULT_FORMAT, customExample: '', customInstructions: '', autotaggingEnabled: false, isAdmin: false, tier: 'free', loading: false }); + setState({ defaultFormat: DEFAULT_FORMAT, customExample: '', customInstructions: '', autotaggingEnabled: false, defaultTagId: null, isAdmin: false, tier: 'free', loading: false }); } }, [session]); @@ -62,6 +65,7 @@ export const PreferencesProvider: React.FC<{ children: ReactNode }> = ({ customExample: prefs?.custom_example ?? '', customInstructions: prefs?.custom_instructions ?? '', autotaggingEnabled: prefs?.autotagging_enabled ?? false, + defaultTagId: prefs?.default_tag_id ?? null, isAdmin: prefs?.is_admin ?? false, tier: prefs?.tier ?? 'free', loading: false, @@ -116,6 +120,17 @@ export const PreferencesProvider: React.FC<{ children: ReactNode }> = ({ } }, [state.autotaggingEnabled]); + const setDefaultTagId = useCallback(async (tagId: string | null) => { + const previous = state.defaultTagId; + setState((prev) => ({ ...prev, defaultTagId: tagId })); + try { + await preferencesService.setDefaultTagId(tagId); + } catch (error) { + setState((prev) => ({ ...prev, defaultTagId: previous })); + throw error; + } + }, [state.defaultTagId]); + return ( = ({ setCustomExample, setCustomInstructions, setAutotaggingEnabled, + setDefaultTagId, }} > {children} diff --git a/services/notes.ts b/services/notes.ts index 8085172..7d5a8ff 100644 --- a/services/notes.ts +++ b/services/notes.ts @@ -1,13 +1,29 @@ import { supabase } from './supabase'; -import { Note, CreateNoteInput, UpdateNoteInput } from '../types'; +import { Note, CreateNoteInput, UpdateNoteInput, NoteSort } from '../types'; -export const fetchNotes = async (): Promise => { - const { data, error } = await supabase +export const fetchNotes = async (sort: NoteSort = 'date_desc'): Promise => { + let query = supabase .from('notes') .select('*') .is('deleted_at', null) - .is('archived_at', null) - .order('created_at', { ascending: false }); + .is('archived_at', null); + + switch (sort) { + case 'date_asc': + query = query.order('created_at', { ascending: true }); + break; + case 'title_asc': + query = query.order('title', { ascending: true }); + break; + case 'title_desc': + query = query.order('title', { ascending: false }); + break; + case 'date_desc': + default: + query = query.order('created_at', { ascending: false }); + } + + const { data, error } = await query; if (error) throw error; return data || []; }; diff --git a/services/preferences.ts b/services/preferences.ts index e3b0c00..27a3b6f 100644 --- a/services/preferences.ts +++ b/services/preferences.ts @@ -93,3 +93,19 @@ export const setAutotaggingEnabled = async (enabled: boolean): Promise => if (error) throw error; }; + +export const setDefaultTagId = async (tagId: string | null): Promise => { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) throw new Error('Not authenticated'); + + const { error } = await supabase + .from('user_preferences') + .upsert( + { user_id: user.id, default_tag_id: tagId }, + { onConflict: 'user_id' } + ); + + if (error) throw error; +}; diff --git a/supabase/migrations/019_default_tag.sql b/supabase/migrations/019_default_tag.sql new file mode 100644 index 0000000..7667b77 --- /dev/null +++ b/supabase/migrations/019_default_tag.sql @@ -0,0 +1,3 @@ +-- Add default_tag_id preference so users can land on their most-used filter +ALTER TABLE user_preferences + ADD COLUMN IF NOT EXISTS default_tag_id uuid REFERENCES tags(id) ON DELETE SET NULL; From 115a03d1bd92ee6d660083d42e692c6eb120d7ea Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Fri, 27 Mar 2026 07:44:59 +0100 Subject: [PATCH 03/17] feat: default filter tag selector in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a tag pill selector in the Tags settings section so users can tap any tag (or None) to set it as their landing filter — persists across sessions. Co-Authored-By: Claude Sonnet 4.6 --- app/settings.tsx | 112 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/app/settings.tsx b/app/settings.tsx index 5497953..39939bd 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -41,11 +41,13 @@ export default function SettingsScreen() { customExample, customInstructions, autotaggingEnabled, + defaultTagId, isAdmin, setDefaultFormat, setCustomExample, setCustomInstructions, setAutotaggingEnabled, + setDefaultTagId, } = usePreferences(); const { tags, createTag, deleteTag } = useTags(); @@ -455,6 +457,63 @@ export default function SettingsScreen() { )} + {/* Default tag selector */} + {tags.length > 0 && ( + + Default filter + + The app opens with this tag pre-selected. + + + setDefaultTagId(null)} + > + + None + + {defaultTagId === null && ( + + )} + + {tags.map((tag) => ( + setDefaultTagId(tag.id)} + > + + + {tag.name} + + {defaultTagId === tag.id && ( + + )} + + ))} + + + )} + {/* Create new tag */} Date: Fri, 27 Mar 2026 07:45:07 +0100 Subject: [PATCH 04/17] feat: interactive acronym/term validation after transcription (#2) After Whisper transcribes audio, a GPT-5-nano call flags low-confidence terms (acronyms, proper nouns, brand names). A bottom-sheet modal shows each uncertain term with its suggested correction; users can edit or skip. Confirmed corrections are saved to user_validated_terms (migration 020) and injected into future formatting prompts so the LLM uses the right spelling. - detectUncertainTerms() in edge function: post-transcription GPT pass - validated_terms injected into buildSystemPrompt for format_only mode - AcronymValidationModal: editable pill list with skip-per-term support - saveValidatedTerms / loadValidatedTerms services with Supabase upsert - recording/[id].tsx pauses auto-advance for modal when terms are found Co-Authored-By: Claude Sonnet 4.6 --- app/recording/[id].tsx | 83 +++++- components/AcronymValidationModal.tsx | 238 ++++++++++++++++++ services/processing.ts | 41 ++- services/validatedTerms.ts | 33 +++ supabase/functions/process-recording/index.ts | 88 ++++++- supabase/migrations/020_validated_terms.sql | 17 ++ 6 files changed, 475 insertions(+), 25 deletions(-) create mode 100644 components/AcronymValidationModal.tsx create mode 100644 services/validatedTerms.ts create mode 100644 supabase/migrations/020_validated_terms.sql diff --git a/app/recording/[id].tsx b/app/recording/[id].tsx index 49a61bf..484fdad 100644 --- a/app/recording/[id].tsx +++ b/app/recording/[id].tsx @@ -11,11 +11,13 @@ import { useNotes } from '../../contexts/NotesContext'; import { usePreferences } from '../../contexts/PreferencesContext'; import { useTags } from '../../contexts/TagsContext'; import { transcribeRecording, formatTranscription } from '../../services/processing'; +import { loadValidatedTerms, saveValidatedTerms } from '../../services/validatedTerms'; import { Colors } from '../../constants/colors'; import { FORMAT_OPTIONS } from '../../constants/formats'; -import { FormatType, RecordingStatus } from '../../types'; +import { FormatType, RecordingStatus, UncertainTerm } from '../../types'; import { showAlert, showConfirm } from '../../utils/alert'; import { AudioPlaybackBar } from '../../components/AudioPlaybackBar'; +import { AcronymValidationModal } from '../../components/AcronymValidationModal'; // Status badge colors const STATUS_COLORS: Record = { @@ -65,6 +67,11 @@ export default function RecordingDetailScreen() { const [saving, setSaving] = useState(false); const [reformatInstructions, setReformatInstructions] = useState(customInstructions ?? ''); + // Acronym validation state + const [pendingTerms, setPendingTerms] = useState([]); + const [showValidationModal, setShowValidationModal] = useState(false); + const [pendingFormatType, setPendingFormatType] = useState(null); + // E4-S3: Auto-advance guard — prevents double-fire in React StrictMode const autoAdvanceRef = useRef(false); @@ -85,8 +92,17 @@ export default function RecordingDetailScreen() { setActionError(null); try { await updateRecording(id, { status: 'transcribing' }); - const rawTranscription = await transcribeRecording(recording.localUri); - await updateRecording(id, { status: 'transcribed', rawTranscription }); + const { transcription: rawTranscription, uncertainTerms } = + await transcribeRecording(recording.localUri); + await updateRecording(id, { status: 'transcribed', rawTranscription, uncertainTerms }); + + // If there are uncertain terms, pause for user validation before formatting + if (uncertainTerms.length > 0) { + setPendingTerms(uncertainTerms); + setPendingFormatType((formatTypeParam as FormatType) ?? defaultFormat); + setShowValidationModal(true); + return; // formatting continues after modal confirmation + } // Step 2: Format (using the format type from navigation params) const formatType = (formatTypeParam as FormatType) ?? defaultFormat; @@ -99,13 +115,15 @@ export default function RecordingDetailScreen() { currentTags = tags.map((t) => t.name); } + const existingTerms = await loadValidatedTerms(); const result = await formatTranscription( rawTranscription, formatType, customExample || undefined, customInstructions || undefined, autotaggingEnabled, - currentTags + currentTags, + existingTerms ); await updateRecording(id, { @@ -150,15 +168,24 @@ export default function RecordingDetailScreen() { setActionError(null); try { await updateRecording(id, { status: 'transcribing' }); - const rawTranscription = await transcribeRecording(recording.localUri); - await updateRecording(id, { status: 'transcribed', rawTranscription }); + const { transcription: rawTranscription, uncertainTerms } = + await transcribeRecording(recording.localUri); + await updateRecording(id, { status: 'transcribed', rawTranscription, uncertainTerms }); + if (uncertainTerms.length > 0) { + setPendingTerms(uncertainTerms); + setPendingFormatType(selectedFormat); + setShowValidationModal(true); + } } catch (err: any) { await updateRecording(id, { status: 'pending' }); setActionError(err.message || 'Transcription failed. Please try again.'); } }; - const handleFormat = async (formatType: FormatType) => { + const handleFormat = async ( + formatType: FormatType, + confirmedTerms?: Array<{ original_term: string; corrected_term: string }> + ) => { setShowFormatPicker(false); setActionError(null); try { @@ -174,13 +201,22 @@ export default function RecordingDetailScreen() { currentTags = tagList.map((t) => t.name); } + const existingTerms = await loadValidatedTerms(); + // Merge confirmed terms with existing (confirmed take precedence via upsert) + const allValidatedTerms = confirmedTerms + ? [...existingTerms.filter( + (e) => !confirmedTerms.some((c) => c.original_term === e.original_term) + ), ...confirmedTerms] + : existingTerms; + const result = await formatTranscription( recording.rawTranscription!, formatType, customExample || undefined, reformatInstructions || undefined, autotaggingEnabled, - currentTags + currentTags, + allValidatedTerms ); await updateRecording(id, { @@ -195,6 +231,31 @@ export default function RecordingDetailScreen() { } }; + const handleValidationConfirm = async ( + validated: Array<{ original_term: string; corrected_term: string }> + ) => { + setShowValidationModal(false); + setPendingTerms([]); + // Persist the validated terms for future use + try { + await saveValidatedTerms(validated); + } catch { + // Non-fatal: continue even if persistence fails + } + // Proceed with formatting using the confirmed corrections + const fmt = pendingFormatType ?? selectedFormat; + setPendingFormatType(null); + await handleFormat(fmt, validated); + }; + + const handleValidationSkip = () => { + setShowValidationModal(false); + setPendingTerms([]); + const fmt = pendingFormatType ?? selectedFormat; + setPendingFormatType(null); + handleFormat(fmt); + }; + const handleSaveAsNote = async () => { if (!recording.formattedText || !recording.formattedTitle) return; setSaving(true); @@ -265,6 +326,12 @@ export default function RecordingDetailScreen() { return ( + {/* Top bar */} router.back()} style={styles.topButton}> diff --git a/components/AcronymValidationModal.tsx b/components/AcronymValidationModal.tsx new file mode 100644 index 0000000..f7ab504 --- /dev/null +++ b/components/AcronymValidationModal.tsx @@ -0,0 +1,238 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + View, + Text, + TextInput, + Pressable, + ScrollView, + StyleSheet, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { Colors } from '../constants/colors'; +import { UncertainTerm } from '../types'; + +interface ValidatedEntry { + original: string; + corrected: string; + skip: boolean; +} + +interface Props { + visible: boolean; + terms: UncertainTerm[]; + onConfirm: (validated: Array<{ original_term: string; corrected_term: string }>) => void; + onSkip: () => void; +} + +export const AcronymValidationModal: React.FC = ({ + visible, + terms, + onConfirm, + onSkip, +}) => { + const [entries, setEntries] = useState([]); + + useEffect(() => { + setEntries( + terms.map((t) => ({ + original: t.original, + corrected: t.suggestion ?? t.original, + skip: false, + })) + ); + }, [terms]); + + const updateCorrected = (index: number, value: string) => { + setEntries((prev) => + prev.map((e, i) => (i === index ? { ...e, corrected: value, skip: false } : e)) + ); + }; + + const toggleSkip = (index: number) => { + setEntries((prev) => + prev.map((e, i) => (i === index ? { ...e, skip: !e.skip } : e)) + ); + }; + + const handleConfirm = () => { + const validated = entries + .filter((e) => !e.skip && e.corrected.trim() && e.corrected.trim() !== e.original) + .map((e) => ({ original_term: e.original, corrected_term: e.corrected.trim() })); + onConfirm(validated); + }; + + if (terms.length === 0) return null; + + return ( + + + + + Check these terms + + The transcription may have misrecognised these words. Correct them to + improve this note and future ones. + + + + {entries.map((entry, i) => ( + + + Heard as: + "{entry.original}" + toggleSkip(i)} style={styles.skipButton}> + {entry.skip ? 'Undo skip' : 'Skip'} + + + {!entry.skip && ( + updateCorrected(i, v)} + placeholder="Correct spelling..." + placeholderTextColor={Colors.textTertiary} + autoCapitalize="none" + autoCorrect={false} + /> + )} + + ))} + + + + [styles.confirmButton, pressed && { opacity: 0.85 }]} + onPress={handleConfirm} + > + Save & Continue + + [styles.skipAllButton, pressed && { opacity: 0.7 }]} + onPress={onSkip} + > + Skip all + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.4)', + }, + sheet: { + backgroundColor: Colors.background, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 24, + paddingBottom: 40, + maxHeight: '80%', + }, + handle: { + width: 40, + height: 4, + borderRadius: 2, + backgroundColor: Colors.borderLight, + alignSelf: 'center', + marginTop: 12, + marginBottom: 20, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: Colors.text, + marginBottom: 6, + }, + subtitle: { + fontSize: 14, + color: Colors.textTertiary, + lineHeight: 20, + marginBottom: 20, + }, + termsList: { + flexGrow: 0, + marginBottom: 20, + }, + termRow: { + backgroundColor: Colors.surface, + borderRadius: 14, + padding: 14, + marginBottom: 10, + borderWidth: 1, + borderColor: Colors.borderLight, + }, + termRowSkipped: { + opacity: 0.5, + }, + termHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + gap: 6, + }, + originalLabel: { + fontSize: 12, + color: Colors.textTertiary, + fontWeight: '500', + }, + originalText: { + fontSize: 13, + color: Colors.textSecondary, + fontStyle: 'italic', + flex: 1, + }, + skipButton: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 8, + backgroundColor: Colors.surfaceHover, + }, + skipText: { + fontSize: 12, + color: Colors.textTertiary, + fontWeight: '500', + }, + correctedInput: { + borderWidth: 1.5, + borderColor: Colors.primary, + borderRadius: 10, + padding: 10, + fontSize: 15, + color: Colors.text, + backgroundColor: Colors.background, + fontWeight: '500', + }, + actions: { + gap: 10, + }, + confirmButton: { + backgroundColor: Colors.primary, + borderRadius: 14, + paddingVertical: 16, + alignItems: 'center', + }, + confirmText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '700', + }, + skipAllButton: { + paddingVertical: 12, + alignItems: 'center', + }, + skipAllText: { + color: Colors.textTertiary, + fontSize: 14, + fontWeight: '500', + }, +}); diff --git a/services/processing.ts b/services/processing.ts index e037ff8..4aa1da9 100644 --- a/services/processing.ts +++ b/services/processing.ts @@ -1,7 +1,12 @@ import { Platform } from 'react-native'; import * as FileSystem from 'expo-file-system/legacy'; import { supabase } from './supabase'; -import { ProcessingResult, FormatType } from '../types'; +import { ProcessingResult, FormatType, UncertainTerm } from '../types'; + +export interface TranscribeResult { + transcription: string; + uncertainTerms: UncertainTerm[]; +} const WHISPER_MAX_BYTES = 25 * 1024 * 1024; // 25 MB — OpenAI Whisper API limit const CHUNK_BYTES = 24 * 1024 * 1024; // 24 MB per chunk (1 MB safety margin) @@ -18,7 +23,7 @@ function sliceBlob(blob: Blob): Blob[] { export const transcribeRecording = async ( localUri: string -): Promise => { +): Promise => { const { data: { user }, error: userError } = await supabase.auth.getUser(); if (userError || !user) throw new Error('Not authenticated'); @@ -28,7 +33,7 @@ export const transcribeRecording = async ( const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? ''; - const sendChunk = async (formData: FormData): Promise => { + const sendChunk = async (formData: FormData): Promise => { const result = await fetch( `${supabaseUrl}/functions/v1/process-recording`, { @@ -47,7 +52,10 @@ export const transcribeRecording = async ( ); } const data = await result.json(); - return data.transcription as string; + return { + transcription: data.transcription as string, + uncertainTerms: (data.uncertain_terms as UncertainTerm[]) ?? [], + }; }; if (Platform.OS === 'web') { @@ -62,15 +70,17 @@ export const transcribeRecording = async ( } // File exceeds 25 MB: split, transcribe each chunk, join results + // Uncertain terms from fragment chunks are meaningless — skip them const chunks = sliceBlob(blob); const parts: string[] = []; for (let i = 0; i < chunks.length; i++) { const formData = new FormData(); formData.append('audio', chunks[i] as any, `recording-part${i + 1}.webm`); formData.append('mode', 'transcribe_only'); - parts.push(await sendChunk(formData)); + const { transcription } = await sendChunk(formData); + parts.push(transcription); } - return parts.join(' '); + return { transcription: parts.join(' '), uncertainTerms: [] }; } // Native: check file size before deciding whether to split @@ -111,9 +121,11 @@ export const transcribeRecording = async ( const formData = new FormData(); formData.append('audio', { uri: tempUris[j], type: 'audio/m4a', name: `recording-part${j + 1}.m4a` } as any); formData.append('mode', 'transcribe_only'); - parts.push(await sendChunk(formData)); + const { transcription } = await sendChunk(formData); + parts.push(transcription); } - return parts.join(' '); + // Uncertain terms from fragment chunks are meaningless — skip them + return { transcription: parts.join(' '), uncertainTerms: [] }; } finally { await Promise.all(tempUris.map(uri => FileSystem.deleteAsync(uri, { idempotent: true }))); } @@ -125,7 +137,8 @@ export const formatTranscription = async ( customExample?: string, customInstructions?: string, autotaggingEnabled?: boolean, - userTagNames?: string[] + userTagNames?: string[], + validatedTerms?: Array<{ original_term: string; corrected_term: string }> ): Promise => { const { data: { user }, error: userError } = await supabase.auth.getUser(); if (userError || !user) throw new Error('Not authenticated'); @@ -148,6 +161,9 @@ export const formatTranscription = async ( formData.append('autotagging_enabled', 'true'); formData.append('user_tags', JSON.stringify(userTagNames ?? [])); } + if (validatedTerms && validatedTerms.length > 0) { + formData.append('validated_terms', JSON.stringify(validatedTerms)); + } const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY; @@ -181,9 +197,10 @@ export const processRecording = async ( customInstructions?: string, autotaggingEnabled?: boolean, userTagNames?: string[] -): Promise => { +): Promise => { // Step 1: transcribe - const rawTranscription = await transcribeRecording(audioUri); + const { transcription: rawTranscription, uncertainTerms } = + await transcribeRecording(audioUri); // Step 2: format const result = await formatTranscription( @@ -195,5 +212,5 @@ export const processRecording = async ( userTagNames ); - return result; + return { ...result, uncertainTerms }; }; diff --git a/services/validatedTerms.ts b/services/validatedTerms.ts new file mode 100644 index 0000000..ecc4697 --- /dev/null +++ b/services/validatedTerms.ts @@ -0,0 +1,33 @@ +import { supabase } from './supabase'; + +export interface ValidatedTerm { + original_term: string; + corrected_term: string; +} + +export const loadValidatedTerms = async (): Promise => { + const { data, error } = await supabase + .from('user_validated_terms') + .select('original_term, corrected_term'); + if (error) throw error; + return data ?? []; +}; + +export const saveValidatedTerms = async (terms: ValidatedTerm[]): Promise => { + if (terms.length === 0) return; + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) throw new Error('Not authenticated'); + + const rows = terms.map((t) => ({ + user_id: user.id, + original_term: t.original_term, + corrected_term: t.corrected_term, + })); + + const { error } = await supabase + .from('user_validated_terms') + .upsert(rows, { onConflict: 'user_id,original_term' }); + if (error) throw error; +}; diff --git a/supabase/functions/process-recording/index.ts b/supabase/functions/process-recording/index.ts index fb25208..bb473da 100644 --- a/supabase/functions/process-recording/index.ts +++ b/supabase/functions/process-recording/index.ts @@ -59,7 +59,68 @@ Good title: "App to benchmark LLM providers" — Bad title: "Je veux créer une Always respond in the same language as the transcription, unless the user instructs otherwise. Respond ONLY with a valid JSON object with exactly two fields: "title" (string) and "content" (string containing the formatted note in markdown).`; -function buildSystemPrompt(formatType: string, customExample?: string, customInstructions?: string): string { +async function detectUncertainTerms( + transcription: string +): Promise> { + try { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-5-nano', + reasoning_effort: 'minimal', + messages: [ + { + role: 'system', + content: + 'Identify words or phrases in this transcription that are likely misrecognized by speech-to-text: acronyms, technical terms, proper nouns, brand names, or phonetically ambiguous words. Only flag terms with genuine uncertainty — not common words. For each uncertain term, provide the original as transcribed and your best suggestion (or null if you cannot guess). Return 0 to 5 terms maximum.', + }, + { role: 'user', content: transcription }, + ], + response_format: { + type: 'json_schema', + json_schema: { + name: 'uncertain_terms', + schema: { + type: 'object', + properties: { + uncertain_terms: { + type: 'array', + items: { + type: 'object', + properties: { + original: { type: 'string' }, + suggestion: { type: ['string', 'null'] }, + }, + required: ['original', 'suggestion'], + }, + maxItems: 5, + }, + }, + required: ['uncertain_terms'], + }, + }, + }, + }), + }); + if (!response.ok) return []; + const result = await response.json(); + const parsed = JSON.parse(result.choices[0].message.content); + return parsed.uncertain_terms ?? []; + } catch { + return []; + } +} + +function buildSystemPrompt( + formatType: string, + customExample?: string, + customInstructions?: string, + validatedTerms?: Array<{ original_term: string; corrected_term: string }> +): string { let prompt: string; if (formatType === 'custom' && customExample) { @@ -68,6 +129,14 @@ function buildSystemPrompt(formatType: string, customExample?: string, customIns prompt = `${SYSTEM_PROMPTS[formatType] || SYSTEM_PROMPTS['bullet_list']}\n\n${SHARED_SUFFIX}`; } + // Inject validated term corrections so the LLM uses the right spellings + if (validatedTerms && validatedTerms.length > 0) { + const termsList = validatedTerms + .map((t) => ` - "${t.original_term}" → "${t.corrected_term}"`) + .join('\n'); + prompt += `\n\nKnown term corrections (always use these exact spellings in your output):\n${termsList}`; + } + // Append user custom instructions if provided (applies to all formats) if (customInstructions && customInstructions.trim()) { prompt += `\n\nAdditional user instructions (follow these closely): ${customInstructions.trim()}`; @@ -111,9 +180,10 @@ async function formatTranscription( transcription: string, formatType: string, customExample?: string, - customInstructions?: string + customInstructions?: string, + validatedTerms?: Array<{ original_term: string; corrected_term: string }> ): Promise<{ title: string; content: string }> { - const systemPrompt = buildSystemPrompt(formatType, customExample, customInstructions); + const systemPrompt = buildSystemPrompt(formatType, customExample, customInstructions, validatedTerms); const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', @@ -288,8 +358,9 @@ serve(async (req: Request) => { ); } const transcription = await transcribeAudio(audioFile); + const uncertainTerms = await detectUncertainTerms(transcription); return new Response( - JSON.stringify({ transcription }), + JSON.stringify({ transcription, uncertain_terms: uncertainTerms }), { status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } } ); } @@ -324,11 +395,18 @@ serve(async (req: Request) => { const fmtSafeExample = fmtCustomExample ? fmtCustomExample.slice(0, MAX_CUSTOM_EXAMPLE_LENGTH) : null; const fmtSafeInstructions = fmtCustomInstructions ? fmtCustomInstructions.slice(0, MAX_CUSTOM_INSTRUCTIONS_LENGTH) : null; + const fmtValidatedTermsRaw = formData.get('validated_terms') as string | null; + let fmtValidatedTerms: Array<{ original_term: string; corrected_term: string }> = []; + if (fmtValidatedTermsRaw) { + try { fmtValidatedTerms = JSON.parse(fmtValidatedTermsRaw); } catch { fmtValidatedTerms = []; } + } + const formatted = await formatTranscription( rawTranscription, fmtFormatType, fmtSafeExample || undefined, - fmtSafeInstructions || undefined + fmtSafeInstructions || undefined, + fmtValidatedTerms.length > 0 ? fmtValidatedTerms : undefined ); let fmtSuggestedTags: string[] = []; diff --git a/supabase/migrations/020_validated_terms.sql b/supabase/migrations/020_validated_terms.sql new file mode 100644 index 0000000..990fb40 --- /dev/null +++ b/supabase/migrations/020_validated_terms.sql @@ -0,0 +1,17 @@ +-- Store user-validated term corrections from transcriptions +-- Allows the app to reuse confirmed term spellings in future prompts +CREATE TABLE IF NOT EXISTS user_validated_terms ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, + original_term text NOT NULL, + corrected_term text NOT NULL, + created_at timestamptz DEFAULT now(), + UNIQUE(user_id, original_term) +); + +ALTER TABLE user_validated_terms ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage their own validated terms" + ON user_validated_terms FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); From 6e1f64fa5dde148b1a7a213a2290b43132ba0551 Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Sun, 29 Mar 2026 22:10:49 +0200 Subject: [PATCH 05/17] fix: resolve 3 bugs found in new features review - AcronymValidationModal: deduplicate uncertain terms by original field to prevent duplicate React keys and repeated entries in the modal - services/notes: add 'id' as secondary sort key in all fetchNotes cases to ensure stable ordering when primary sort values are equal - NotesContext: persist sort preference to AsyncStorage so it survives app restarts instead of always resetting to 'date_desc' Co-Authored-By: Claude Sonnet 4.6 --- components/AcronymValidationModal.tsx | 17 ++++++++++++----- contexts/NotesContext.tsx | 13 +++++++++++++ services/notes.ts | 8 ++++---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/components/AcronymValidationModal.tsx b/components/AcronymValidationModal.tsx index f7ab504..95263d3 100644 --- a/components/AcronymValidationModal.tsx +++ b/components/AcronymValidationModal.tsx @@ -35,12 +35,19 @@ export const AcronymValidationModal: React.FC = ({ const [entries, setEntries] = useState([]); useEffect(() => { + const seen = new Set(); setEntries( - terms.map((t) => ({ - original: t.original, - corrected: t.suggestion ?? t.original, - skip: false, - })) + terms + .filter((t) => { + if (seen.has(t.original)) return false; + seen.add(t.original); + return true; + }) + .map((t) => ({ + original: t.original, + corrected: t.suggestion ?? t.original, + skip: false, + })) ); }, [terms]); diff --git a/contexts/NotesContext.tsx b/contexts/NotesContext.tsx index 65fe158..6aef604 100644 --- a/contexts/NotesContext.tsx +++ b/contexts/NotesContext.tsx @@ -7,10 +7,13 @@ import React, { useMemo, ReactNode, } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { Note, CreateNoteInput, UpdateNoteInput, NoteSort } from '../types'; import * as notesService from '../services/notes'; import { useAuth } from './AuthContext'; +const SORT_KEY = '@voicekeeper/notes_sort'; + interface NotesState { notes: Note[]; loading: boolean; @@ -46,6 +49,15 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ sort: 'date_desc', }); + // Restore persisted sort on mount + useEffect(() => { + AsyncStorage.getItem(SORT_KEY).then((saved) => { + if (saved) { + setState((prev) => ({ ...prev, sort: saved as NoteSort })); + } + }).catch(() => {}); + }, []); + const fetchNotes = useCallback(async () => { setState((prev) => ({ ...prev, loading: true })); try { @@ -159,6 +171,7 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ const setSort = useCallback((sort: NoteSort) => { setState((prev) => ({ ...prev, sort })); + AsyncStorage.setItem(SORT_KEY, sort).catch(() => {}); }, []); const filteredNotes = useMemo(() => { diff --git a/services/notes.ts b/services/notes.ts index 7d5a8ff..af810d1 100644 --- a/services/notes.ts +++ b/services/notes.ts @@ -10,17 +10,17 @@ export const fetchNotes = async (sort: NoteSort = 'date_desc'): Promise switch (sort) { case 'date_asc': - query = query.order('created_at', { ascending: true }); + query = query.order('created_at', { ascending: true }).order('id', { ascending: true }); break; case 'title_asc': - query = query.order('title', { ascending: true }); + query = query.order('title', { ascending: true }).order('id', { ascending: true }); break; case 'title_desc': - query = query.order('title', { ascending: false }); + query = query.order('title', { ascending: false }).order('id', { ascending: true }); break; case 'date_desc': default: - query = query.order('created_at', { ascending: false }); + query = query.order('created_at', { ascending: false }).order('id', { ascending: true }); } const { data, error } = await query; From a5bbec1634266fe828e1b5e6a0f42b08f3b15592 Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Mon, 30 Mar 2026 08:12:14 +0200 Subject: [PATCH 06/17] fix: restore proper json_schema structured output for uncertain terms detection Switch back from json_object to json_schema with strict mode enabled. Previous strict schema failed silently due to: missing strict:true, invalid type:["string","null"] syntax (must use anyOf), unsupported maxItems, and missing additionalProperties:false at each level. Co-Authored-By: Claude Sonnet 4.6 --- supabase/functions/process-recording/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/supabase/functions/process-recording/index.ts b/supabase/functions/process-recording/index.ts index bb473da..9fdca36 100644 --- a/supabase/functions/process-recording/index.ts +++ b/supabase/functions/process-recording/index.ts @@ -84,6 +84,7 @@ async function detectUncertainTerms( type: 'json_schema', json_schema: { name: 'uncertain_terms', + strict: true, schema: { type: 'object', properties: { @@ -93,14 +94,15 @@ async function detectUncertainTerms( type: 'object', properties: { original: { type: 'string' }, - suggestion: { type: ['string', 'null'] }, + suggestion: { anyOf: [{ type: 'string' }, { type: 'null' }] }, }, required: ['original', 'suggestion'], + additionalProperties: false, }, - maxItems: 5, }, }, required: ['uncertain_terms'], + additionalProperties: false, }, }, }, @@ -108,7 +110,8 @@ async function detectUncertainTerms( }); if (!response.ok) return []; const result = await response.json(); - const parsed = JSON.parse(result.choices[0].message.content); + const content = result.choices[0].message.content; + const parsed = typeof content === 'string' ? JSON.parse(content) : content; return parsed.uncertain_terms ?? []; } catch { return []; From 41eaabef9b2b4461a726f6ab39bc1adac517fe15 Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Mon, 30 Mar 2026 08:17:07 +0200 Subject: [PATCH 07/17] fix: inject validated terms into note reformat flow The handleReformat function in note/[id].tsx was calling formatTranscription without loading or passing the user's saved validated terms, so previous acronym corrections were not applied when reformatting an existing note. Co-Authored-By: Claude Sonnet 4.6 --- app/note/[id].tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/note/[id].tsx b/app/note/[id].tsx index 60c8508..5fb9182 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -24,6 +24,7 @@ import { Colors } from '../../constants/colors'; import { FORMAT_OPTIONS } from '../../constants/formats'; import { formatDate } from '../../utils/titleGenerator'; import { formatTranscription } from '../../services/processing'; +import { loadValidatedTerms } from '../../services/validatedTerms'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Tag, FormatType } from '../../types'; import { AudioPlaybackBar } from '../../components/AudioPlaybackBar'; @@ -342,11 +343,15 @@ export default function NoteDetailScreen() { setSelectedFormat(formatType); setReformatting(true); try { + const validatedTerms = await loadValidatedTerms(); const result = await formatTranscription( editText, formatType, formatType === 'custom' ? customExample || undefined : undefined, reformatInstructions || undefined, + false, + [], + validatedTerms.length > 0 ? validatedTerms : undefined, ); setEditTitle(result.title); setEditText(result.formatted_text); From 66207a6a78cf1eaea336b7b943b0244c72af06fe Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Mon, 30 Mar 2026 09:42:56 +0200 Subject: [PATCH 08/17] feat: add Saved Terms screen to view and manage validated acronyms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New screen at /acronyms listing all user-confirmed term corrections - Each row shows the original (misheard) term → corrected spelling - Per-term remove button with confirmation dialog - "Clear all" button in the header to wipe all corrections at once - Empty state with explanation of how the feature works - Accessible from Settings > Notes > Saved Terms - Added deleteValidatedTerm / deleteAllValidatedTerms to validatedTerms service Co-Authored-By: Claude Sonnet 4.6 --- app/_layout.tsx | 4 + app/acronyms.tsx | 272 +++++++++++++++++++++++++++++++++++++ app/settings.tsx | 16 +++ services/validatedTerms.ts | 16 +++ 4 files changed, 308 insertions(+) create mode 100644 app/acronyms.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 6f304b7..470db3a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -103,6 +103,10 @@ function RootLayoutNav() { name="trash" options={{ title: 'Trash', headerShown: false }} /> + ); diff --git a/app/acronyms.tsx b/app/acronyms.tsx new file mode 100644 index 0000000..c0abf2c --- /dev/null +++ b/app/acronyms.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + Pressable, + FlatList, + ActivityIndicator, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Colors } from '../constants/colors'; +import { + ValidatedTerm, + loadValidatedTerms, + deleteValidatedTerm, + deleteAllValidatedTerms, +} from '../services/validatedTerms'; +import { showConfirm, showAlert } from '../utils/alert'; + +export default function AcronymsScreen() { + const router = useRouter(); + const [terms, setTerms] = useState([]); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + + const load = useCallback(async () => { + try { + const data = await loadValidatedTerms(); + setTerms(data); + } catch { + showAlert('Error', 'Could not load saved terms.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const handleDelete = (term: ValidatedTerm) => { + showConfirm( + 'Remove term', + `Remove "${term.original_term}" → "${term.corrected_term}"? It will no longer be used in future transcriptions.`, + async () => { + setDeleting(term.original_term); + try { + await deleteValidatedTerm(term.original_term); + setTerms((prev) => prev.filter((t) => t.original_term !== term.original_term)); + } catch { + showAlert('Error', 'Could not remove the term.'); + } finally { + setDeleting(null); + } + } + ); + }; + + const handleClearAll = () => { + if (terms.length === 0) return; + showConfirm( + 'Clear all terms', + 'Remove all saved corrections? This cannot be undone.', + async () => { + try { + await deleteAllValidatedTerms(); + setTerms([]); + } catch { + showAlert('Error', 'Could not clear terms.'); + } + } + ); + }; + + return ( + + + router.back()} + style={({ pressed }) => [styles.backPressable, pressed && { opacity: 0.6 }]} + > + Back + + Saved Terms + {terms.length > 0 ? ( + [styles.clearPressable, pressed && { opacity: 0.6 }]} + > + Clear all + + ) : ( + + )} + + + {loading ? ( + + + + ) : terms.length === 0 ? ( + + 🔤 + No saved corrections + + When the AI flags uncertain terms during transcription and you confirm the correct + spelling, those corrections are saved here and reused automatically in future notes. + + + ) : ( + item.original_term} + contentContainerStyle={styles.listContent} + ListHeaderComponent={ + + {terms.length} saved {terms.length === 1 ? 'correction' : 'corrections'} + + } + renderItem={({ item }) => ( + + + + {item.original_term} + + {item.corrected_term} + + + handleDelete(item)} + disabled={deleting === item.original_term} + style={({ pressed }) => [ + styles.deleteButton, + pressed && { opacity: 0.6 }, + deleting === item.original_term && { opacity: 0.4 }, + ]} + accessibilityLabel={`Remove correction for ${item.original_term}`} + > + Remove + + + )} + ItemSeparatorComponent={() => } + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background, + }, + topBar: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + backPressable: { + paddingVertical: 4, + minWidth: 56, + }, + backText: { + color: Colors.primary, + fontSize: 16, + fontWeight: '600', + }, + topTitle: { + fontSize: 17, + fontWeight: '600', + color: Colors.text, + }, + clearPressable: { + paddingVertical: 4, + minWidth: 72, + alignItems: 'flex-end', + }, + clearText: { + color: Colors.error, + fontSize: 15, + fontWeight: '500', + }, + centered: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + }, + emptyIcon: { + fontSize: 48, + marginBottom: 16, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '700', + color: Colors.text, + marginBottom: 10, + textAlign: 'center', + }, + emptyDesc: { + fontSize: 14, + color: Colors.textTertiary, + textAlign: 'center', + lineHeight: 22, + }, + listContent: { + padding: 20, + paddingBottom: 48, + }, + listHeader: { + fontSize: 13, + fontWeight: '600', + color: Colors.textTertiary, + textTransform: 'uppercase', + letterSpacing: 0.6, + marginBottom: 16, + }, + termRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.surface, + borderRadius: 14, + paddingHorizontal: 16, + paddingVertical: 14, + borderWidth: 1, + borderColor: Colors.borderLight, + }, + termTexts: { + flex: 1, + }, + termPair: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: 8, + }, + originalText: { + fontSize: 14, + color: Colors.textSecondary, + fontStyle: 'italic', + }, + arrow: { + fontSize: 14, + color: Colors.textTertiary, + }, + correctedText: { + fontSize: 15, + fontWeight: '600', + color: Colors.text, + }, + deleteButton: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 8, + backgroundColor: Colors.errorLight, + marginLeft: 12, + }, + deleteText: { + fontSize: 12, + color: Colors.error, + fontWeight: '600', + }, + separator: { + height: 8, + }, +}); diff --git a/app/settings.tsx b/app/settings.tsx index 39939bd..7beefb2 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -403,6 +403,22 @@ export default function SettingsScreen() { + [ + styles.navLink, + pressed && { opacity: 0.7 }, + ]} + onPress={() => router.push('/acronyms')} + > + + 🔤 + + Saved Terms + Corrections reused in future transcriptions + + + + {/* Autotagging */} diff --git a/services/validatedTerms.ts b/services/validatedTerms.ts index ecc4697..1ae3ed4 100644 --- a/services/validatedTerms.ts +++ b/services/validatedTerms.ts @@ -31,3 +31,19 @@ export const saveValidatedTerms = async (terms: ValidatedTerm[]): Promise .upsert(rows, { onConflict: 'user_id,original_term' }); if (error) throw error; }; + +export const deleteValidatedTerm = async (originalTerm: string): Promise => { + const { error } = await supabase + .from('user_validated_terms') + .delete() + .eq('original_term', originalTerm); + if (error) throw error; +}; + +export const deleteAllValidatedTerms = async (): Promise => { + const { error } = await supabase + .from('user_validated_terms') + .delete() + .neq('original_term', ''); + if (error) throw error; +}; From b7b00a4ca2acb6a457f8157825d87378d053c42b Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Mon, 30 Mar 2026 09:51:44 +0200 Subject: [PATCH 09/17] feat: pass known terms to transcribe so already-validated acronyms are not re-flagged - transcribeRecording() now accepts knownTerms and forwards them to the edge function - Both auto-advance and manual transcribe paths load existing validated terms before calling - Edge function detectUncertainTerms() skips terms already confirmed by the user - Formatting system prompt clarified: LLM now told to replace misrecognized spellings Co-Authored-By: Claude Sonnet 4.6 --- app/recording/[id].tsx | 6 ++-- services/processing.ts | 7 ++++- supabase/functions/process-recording/index.ts | 28 +++++++++++++++---- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/app/recording/[id].tsx b/app/recording/[id].tsx index 484fdad..c8f60db 100644 --- a/app/recording/[id].tsx +++ b/app/recording/[id].tsx @@ -92,8 +92,9 @@ export default function RecordingDetailScreen() { setActionError(null); try { await updateRecording(id, { status: 'transcribing' }); + const existingTermsForTranscribe = await loadValidatedTerms(); const { transcription: rawTranscription, uncertainTerms } = - await transcribeRecording(recording.localUri); + await transcribeRecording(recording.localUri, existingTermsForTranscribe.length > 0 ? existingTermsForTranscribe : undefined); await updateRecording(id, { status: 'transcribed', rawTranscription, uncertainTerms }); // If there are uncertain terms, pause for user validation before formatting @@ -168,8 +169,9 @@ export default function RecordingDetailScreen() { setActionError(null); try { await updateRecording(id, { status: 'transcribing' }); + const existingTermsForTranscribe = await loadValidatedTerms(); const { transcription: rawTranscription, uncertainTerms } = - await transcribeRecording(recording.localUri); + await transcribeRecording(recording.localUri, existingTermsForTranscribe.length > 0 ? existingTermsForTranscribe : undefined); await updateRecording(id, { status: 'transcribed', rawTranscription, uncertainTerms }); if (uncertainTerms.length > 0) { setPendingTerms(uncertainTerms); diff --git a/services/processing.ts b/services/processing.ts index 4aa1da9..4388f9b 100644 --- a/services/processing.ts +++ b/services/processing.ts @@ -22,7 +22,8 @@ function sliceBlob(blob: Blob): Blob[] { } export const transcribeRecording = async ( - localUri: string + localUri: string, + knownTerms?: Array<{ original_term: string; corrected_term: string }> ): Promise => { const { data: { user }, error: userError } = await supabase.auth.getUser(); if (userError || !user) throw new Error('Not authenticated'); @@ -58,6 +59,8 @@ export const transcribeRecording = async ( }; }; + const knownTermsJson = knownTerms && knownTerms.length > 0 ? JSON.stringify(knownTerms) : null; + if (Platform.OS === 'web') { const response = await fetch(localUri); const blob = await response.blob(); @@ -66,6 +69,7 @@ export const transcribeRecording = async ( const formData = new FormData(); formData.append('audio', blob as any, 'recording.webm'); formData.append('mode', 'transcribe_only'); + if (knownTermsJson) formData.append('known_terms', knownTermsJson); return sendChunk(formData); } @@ -91,6 +95,7 @@ export const transcribeRecording = async ( const formData = new FormData(); formData.append('audio', { uri: localUri, type: 'audio/m4a', name: 'recording.m4a' } as any); formData.append('mode', 'transcribe_only'); + if (knownTermsJson) formData.append('known_terms', knownTermsJson); return sendChunk(formData); } diff --git a/supabase/functions/process-recording/index.ts b/supabase/functions/process-recording/index.ts index 9fdca36..b981ec1 100644 --- a/supabase/functions/process-recording/index.ts +++ b/supabase/functions/process-recording/index.ts @@ -60,9 +60,20 @@ Always respond in the same language as the transcription, unless the user instru Respond ONLY with a valid JSON object with exactly two fields: "title" (string) and "content" (string containing the formatted note in markdown).`; async function detectUncertainTerms( - transcription: string + transcription: string, + knownTerms?: Array<{ original_term: string; corrected_term: string }> ): Promise> { try { + let systemContent = + 'Identify words or phrases in this transcription that are likely misrecognized by speech-to-text: acronyms, technical terms, proper nouns, brand names, or phonetically ambiguous words. Only flag terms with genuine uncertainty — not common words. For each uncertain term, provide the original as transcribed and your best suggestion (or null if you cannot guess). Return 0 to 5 terms maximum.'; + + if (knownTerms && knownTerms.length > 0) { + const knownList = knownTerms + .map((t) => ` - "${t.original_term}" is already known to mean "${t.corrected_term}"`) + .join('\n'); + systemContent += `\n\nDo NOT flag any of the following — they are already confirmed corrections:\n${knownList}`; + } + const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { @@ -75,8 +86,7 @@ async function detectUncertainTerms( messages: [ { role: 'system', - content: - 'Identify words or phrases in this transcription that are likely misrecognized by speech-to-text: acronyms, technical terms, proper nouns, brand names, or phonetically ambiguous words. Only flag terms with genuine uncertainty — not common words. For each uncertain term, provide the original as transcribed and your best suggestion (or null if you cannot guess). Return 0 to 5 terms maximum.', + content: systemContent, }, { role: 'user', content: transcription }, ], @@ -132,12 +142,12 @@ function buildSystemPrompt( prompt = `${SYSTEM_PROMPTS[formatType] || SYSTEM_PROMPTS['bullet_list']}\n\n${SHARED_SUFFIX}`; } - // Inject validated term corrections so the LLM uses the right spellings + // Inject validated term corrections so the LLM replaces misrecognized words in the transcription if (validatedTerms && validatedTerms.length > 0) { const termsList = validatedTerms .map((t) => ` - "${t.original_term}" → "${t.corrected_term}"`) .join('\n'); - prompt += `\n\nKnown term corrections (always use these exact spellings in your output):\n${termsList}`; + prompt += `\n\nThe transcription may contain speech-to-text errors. Replace every occurrence of the left-hand spelling with the right-hand correct spelling when you encounter it in the transcription:\n${termsList}`; } // Append user custom instructions if provided (applies to all formats) @@ -360,8 +370,14 @@ serve(async (req: Request) => { { status: 413, headers: { 'Content-Type': 'application/json', ...corsHeaders } } ); } + const knownTermsRaw = formData.get('known_terms') as string | null; + let knownTerms: Array<{ original_term: string; corrected_term: string }> = []; + if (knownTermsRaw) { + try { knownTerms = JSON.parse(knownTermsRaw); } catch { knownTerms = []; } + } + const transcription = await transcribeAudio(audioFile); - const uncertainTerms = await detectUncertainTerms(transcription); + const uncertainTerms = await detectUncertainTerms(transcription, knownTerms.length > 0 ? knownTerms : undefined); return new Response( JSON.stringify({ transcription, uncertain_terms: uncertainTerms }), { status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } } From ff8ee3e526bf3d7e986d0b66a72b5ef0948ae974 Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Thu, 2 Apr 2026 05:39:18 +0200 Subject: [PATCH 10/17] fix: replace Recordings text button with icon to prevent header overflow The header had three items (sort pill + "Recordings" text + settings circle) that overflowed on standard screen widths, pushing the settings button off-screen. Convert Recordings to a compact 44x44 icon button (three horizontal bars) matching the settings button style. Both icon buttons now share the same iconButton/iconButtonPressed style. Co-Authored-By: Claude Sonnet 4.6 --- app/index.tsx | 83 +++++++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index 77c1a46..e7a8aff 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -118,27 +118,31 @@ export default function HomeScreen() { [ - styles.headerActionButton, - pressed && { opacity: 0.6 }, + styles.iconButton, + pressed && styles.iconButtonPressed, ]} accessibilityLabel="Recordings" > - Recordings + + + + + [ - styles.settingsButton, - pressed && styles.settingsButtonPressed, - ]} - accessibilityLabel="Settings" - > - - - - - - + onPress={handleSettings} + style={({ pressed }) => [ + styles.iconButton, + pressed && styles.iconButtonPressed, + ]} + accessibilityLabel="Settings" + > + + + + + + @@ -246,10 +250,32 @@ const styles = StyleSheet.create({ fontWeight: '600', color: Colors.textSecondary, }, - recordingsButtonText: { - fontSize: 14, - fontWeight: '600', - color: Colors.primary, + iconButton: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: Colors.surface, + justifyContent: 'center', + alignItems: 'center', + ...Colors.shadow.sm, + }, + iconButtonPressed: { + backgroundColor: Colors.surfaceHover, + transform: [{ scale: 0.95 }], + }, + recordingsIconContainer: { + width: 20, + height: 20, + justifyContent: 'center', + gap: 3, + }, + recordingsBar: { + height: 2.5, + borderRadius: 1.5, + backgroundColor: Colors.primary, + }, + recordingsBarShort: { + width: '60%', }, modalOverlay: { flex: 1, @@ -293,34 +319,19 @@ const styles = StyleSheet.create({ color: Colors.primary, fontWeight: '700', }, - settingsButton: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: Colors.surface, - justifyContent: 'center', - alignItems: 'center', - ...Colors.shadow.sm, - }, - settingsButtonPressed: { - backgroundColor: Colors.surfaceHover, - transform: [{ scale: 0.95 }], - }, settingsIconContainer: { width: 20, height: 20, justifyContent: 'center', alignItems: 'center', + gap: 3, }, gearDot: { width: 4, height: 4, borderRadius: 2, backgroundColor: Colors.textSecondary, - marginVertical: 1.5, }, - gearDot2: {}, - gearDot3: {}, listContainer: { flex: 1, }, From 4b2a4ef3c0a3017e3bde5b5606329d7de95c58fb Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Thu, 2 Apr 2026 06:05:09 +0200 Subject: [PATCH 11/17] fix: header overflow, action items UX, sort placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header: - Move sort control into subtitle row ("10 notes · ↕ Title A→Z"), tappable - Header right side now has only 2 icon buttons (recordings + settings) — no more overflow Action items: - Open in view/checkbox mode by default (not text edit mode) - Add ✕ remove button per item - Unchecked items rendered first; checked items grouped below with a divider - Checked items keep strikethrough; unchecked/checked split updates live Co-Authored-By: Claude Sonnet 4.6 --- app/index.tsx | 47 +++++++++++++------------- app/note/[id].tsx | 86 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 87 insertions(+), 46 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index e7a8aff..19bb2b1 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -93,28 +93,25 @@ export default function HomeScreen() { return ( - + VoiceKeeper - - {displayedNotes.length > 0 - ? `${displayedNotes.length} note${displayedNotes.length !== 1 ? 's' : ''}` - : 'No notes yet'} - - - setShowSortPicker(true)} - style={({ pressed }) => [ - styles.headerActionButton, - pressed && { opacity: 0.6 }, - ]} + style={({ pressed }) => [styles.subtitleRow, pressed && { opacity: 0.6 }]} accessibilityLabel="Sort notes" > - ↕ {currentSortLabel} + + {displayedNotes.length > 0 + ? `${displayedNotes.length} note${displayedNotes.length !== 1 ? 's' : ''}` + : 'No notes yet'} + + · ↕ {currentSortLabel} + + [ @@ -233,22 +230,24 @@ const styles = StyleSheet.create({ color: Colors.textTertiary, marginTop: 2, }, - headerActions: { + headerLeft: { + flex: 1, + marginRight: 12, + }, + subtitleRow: { flexDirection: 'row', alignItems: 'center', - gap: 10, - }, - headerActionButton: { - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 20, - backgroundColor: Colors.surface, - ...Colors.shadow.sm, + marginTop: 2, }, - sortButtonText: { + sortIndicator: { fontSize: 13, + color: Colors.primary, fontWeight: '600', - color: Colors.textSecondary, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, }, iconButton: { width: 44, diff --git a/app/note/[id].tsx b/app/note/[id].tsx index 5fb9182..70ce2e8 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -45,6 +45,17 @@ function ActionItemsList({ const [showInput, setShowInput] = useState(false); const lines = text.split('\n'); + // Parse items preserving original line index for mutations + const parsed = lines.map((line, index) => ({ + line, + index, + unchecked: line.match(/^-\s+\[\s\]\s+(.+)/), + checked: line.match(/^-\s+\[x\]\s+(.+)/i), + })); + const uncheckedItems = parsed.filter((p) => p.unchecked); + const checkedItems = parsed.filter((p) => p.checked); + const otherLines = parsed.filter((p) => !p.unchecked && !p.checked && p.line.trim()); + const toggle = (lineIndex: number) => { const updated = lines.map((line, i) => { if (i !== lineIndex) return line; @@ -55,6 +66,11 @@ function ActionItemsList({ onTextChange?.(updated); }; + const removeItem = (lineIndex: number) => { + const updated = lines.filter((_, i) => i !== lineIndex).join('\n'); + onTextChange?.(updated); + }; + const addItem = () => { const trimmed = newItemText.trim(); if (!trimmed) { setShowInput(false); return; } @@ -65,28 +81,39 @@ function ActionItemsList({ setShowInput(false); }; + const renderItem = (item: (typeof parsed)[0]) => { + const isChecked = !!item.checked; + const label = (item.unchecked ?? item.checked)![1]; + return ( + + toggle(item.index)} + > + {isChecked && } + + + {label} + + removeItem(item.index)} style={actionStyles.removeButton} hitSlop={8}> + + + + ); + }; + return ( - {lines.map((line, i) => { - const unchecked = line.match(/^-\s+\[\s\]\s+(.+)/); - const checked = line.match(/^-\s+\[x\]\s+(.+)/i); - if (unchecked || checked) { - const isChecked = !!checked; - const label = (unchecked ?? checked)![1]; - return ( - toggle(i)}> - - {isChecked && } - - - {label} - - - ); - } - if (!line.trim()) return null; - return {line}; - })} + {otherLines.map((item) => ( + {item.line} + ))} + {uncheckedItems.map(renderItem)} + {checkedItems.length > 0 && ( + <> + + {checkedItems.map(renderItem)} + + )} {allowAdd && ( showInput ? ( @@ -157,6 +184,19 @@ const actionStyles = StyleSheet.create({ color: Colors.textTertiary, textDecorationLine: 'line-through', }, + removeButton: { + paddingHorizontal: 6, + paddingVertical: 4, + }, + removeText: { + fontSize: 13, + color: Colors.textTertiary, + }, + divider: { + height: 1, + backgroundColor: Colors.borderLight, + marginVertical: 12, + }, addButton: { marginTop: 12, paddingVertical: 8, @@ -216,8 +256,9 @@ export default function NoteDetailScreen() { const note = useMemo(() => notes.find((n) => n.id === id), [notes, id]); - // Edit mode is active by default but we do NOT auto-focus the keyboard (#9) - const [isEditing, setIsEditing] = useState(true); + // action_items notes use checkbox UI as primary — start in view mode. + // Other notes start in edit mode without auto-focus (#9). + const [isEditing, setIsEditing] = useState(() => note?.format_type !== 'action_items'); const [editTitle, setEditTitle] = useState(note?.title ?? ''); const [editText, setEditText] = useState(note?.formatted_text ?? ''); const [editFormatType, setEditFormatType] = useState(note?.format_type ?? 'paragraph'); @@ -237,6 +278,7 @@ export default function NoteDetailScreen() { setEditTitle(note.title); setEditText(note.formatted_text); setEditFormatType(note.format_type); + setIsEditing(note.format_type !== 'action_items'); } }, [note?.id]); From 3dfc1582c514cc6da39ca204cab8494c7077efcd Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Thu, 2 Apr 2026 06:21:41 +0200 Subject: [PATCH 12/17] feat: drag-to-reorder notes with manual sort mode Adds a 'Manual order' sort option. When selected, notes display with drag handles on the right. Dragging reorders the list and persists the order to AsyncStorage. New notes are appended at the end of the saved order. - types/index.ts: add 'manual' to NoteSort - services/notes.ts: manual sort fetches by date_desc (order applied client-side) - contexts/NotesContext.tsx: manualOrder state, setManualOrder, filteredNotes applies saved order - components/DraggableNoteList.tsx: PanResponder-based drag-to-reorder list - components/NoteGrid.tsx: conditionally renders DraggableNoteList when draggable=true - app/index.tsx: wire up Manual sort option and pass draggable/onReorder to NoteGrid Co-Authored-By: Claude Sonnet 4.6 --- app/index.tsx | 5 +- components/DraggableNoteList.tsx | 196 +++++++++++++++++++++++++++++++ components/NoteGrid.tsx | 17 +++ contexts/NotesContext.tsx | 55 ++++++--- services/notes.ts | 1 + types/index.ts | 2 +- 6 files changed, 260 insertions(+), 16 deletions(-) create mode 100644 components/DraggableNoteList.tsx diff --git a/app/index.tsx b/app/index.tsx index 19bb2b1..bb31c35 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -18,11 +18,12 @@ const SORT_OPTIONS: { value: NoteSort; label: string }[] = [ { value: 'date_asc', label: 'Oldest first' }, { value: 'title_asc', label: 'Title A→Z' }, { value: 'title_desc', label: 'Title Z→A' }, + { value: 'manual', label: 'Manual order' }, ]; export default function HomeScreen() { const router = useRouter(); - const { filteredNotes, loading, searchQuery, setSearchQuery, fetchNotes, sort, setSort } = + const { filteredNotes, loading, searchQuery, setSearchQuery, fetchNotes, sort, setSort, setManualOrder } = useNotes(); const { tags, refreshNoteTagsMap } = useTags(); const { defaultTagId } = usePreferences(); @@ -158,6 +159,8 @@ export default function HomeScreen() { onNotePress={handleNotePress} onRefresh={handleRefresh} hasActiveFilter={selectedTagId !== null} + draggable={sort === 'manual'} + onReorder={setManualOrder} /> diff --git a/components/DraggableNoteList.tsx b/components/DraggableNoteList.tsx new file mode 100644 index 0000000..3d6f898 --- /dev/null +++ b/components/DraggableNoteList.tsx @@ -0,0 +1,196 @@ +import React, { useRef, useState, useCallback } from 'react'; +import { + View, + ScrollView, + PanResponder, + Animated, + StyleSheet, + RefreshControl, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Note } from '../types'; +import { NoteCard } from './NoteCard'; +import { Colors } from '../constants/colors'; + +interface DraggableNoteListProps { + notes: Note[]; + onNotePress: (note: Note) => void; + onReorder: (newIds: string[]) => void; + onRefresh: () => void; + loading: boolean; +} + +export const DraggableNoteList: React.FC = ({ + notes, + onNotePress, + onReorder, + onRefresh, + loading, +}) => { + const insets = useSafeAreaInsets(); + const scrollRef = useRef(null); + const scrollOffset = useRef(0); + + // Per-item heights accumulated from onLayout + const itemHeights = useRef([]); + + // Active drag state + const [draggingIndex, setDraggingIndex] = useState(null); + const dragTranslateY = useRef(new Animated.Value(0)).current; + const activeDeltaY = useRef(0); + + const getItemTop = (index: number) => + itemHeights.current.slice(0, index).reduce((s, h) => s + h, 0); + + // Determine where to insert the dragged item based on its current midpoint Y + const getInsertIndex = (startIndex: number, deltaY: number): number => { + const movedTop = getItemTop(startIndex) + deltaY; + const movedMid = movedTop + (itemHeights.current[startIndex] ?? 80) / 2; + + let cumY = 0; + for (let i = 0; i < notes.length; i++) { + if (i === startIndex) { + cumY += itemHeights.current[i] ?? 80; + continue; + } + const h = itemHeights.current[i] ?? 80; + if (movedMid < cumY + h / 2) return i; + cumY += h; + } + return notes.length - 1; + }; + + const createPanResponder = useCallback( + (noteIndex: number) => + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + dragTranslateY.setValue(0); + activeDeltaY.current = 0; + setDraggingIndex(noteIndex); + scrollRef.current?.setNativeProps({ scrollEnabled: false }); + }, + onPanResponderMove: (_, gesture) => { + activeDeltaY.current = gesture.dy; + dragTranslateY.setValue(gesture.dy); + }, + onPanResponderRelease: () => { + const delta = activeDeltaY.current; + const insertIdx = getInsertIndex(noteIndex, delta); + + if (insertIdx !== noteIndex) { + const reordered = [...notes]; + const [moved] = reordered.splice(noteIndex, 1); + reordered.splice(insertIdx, 0, moved); + onReorder(reordered.map((n) => n.id)); + } + + dragTranslateY.setValue(0); + setDraggingIndex(null); + scrollRef.current?.setNativeProps({ scrollEnabled: true }); + }, + onPanResponderTerminate: () => { + dragTranslateY.setValue(0); + setDraggingIndex(null); + scrollRef.current?.setNativeProps({ scrollEnabled: true }); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [notes, onReorder] + ); + + return ( + { scrollOffset.current = e.nativeEvent.contentOffset.y; }} + scrollEventThrottle={16} + contentContainerStyle={[styles.content, { paddingBottom: 120 + insets.bottom }]} + refreshControl={ + + } + > + {notes.map((note, index) => { + const isDragging = draggingIndex === index; + const panResponder = createPanResponder(index); + + return ( + { + itemHeights.current[index] = e.nativeEvent.layout.height; + }} + style={styles.itemWrapper} + > + + + + + + + + + + + + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + content: { + padding: 20, + }, + itemWrapper: { + marginBottom: 12, + }, + animatedItem: { + backgroundColor: 'transparent', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + cardWrapper: { + flex: 1, + }, + dragHandle: { + width: 32, + alignItems: 'center', + justifyContent: 'center', + gap: 4, + paddingVertical: 20, + paddingHorizontal: 4, + }, + dragHandleActive: { + opacity: 0.5, + }, + dragBar: { + width: 18, + height: 2.5, + borderRadius: 1.5, + backgroundColor: Colors.textTertiary, + }, +}); diff --git a/components/NoteGrid.tsx b/components/NoteGrid.tsx index fd4a45c..41bbb1e 100644 --- a/components/NoteGrid.tsx +++ b/components/NoteGrid.tsx @@ -10,6 +10,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Note } from '../types'; import { NoteCard } from './NoteCard'; +import { DraggableNoteList } from './DraggableNoteList'; import { Colors } from '../constants/colors'; interface NoteGridProps { @@ -18,6 +19,8 @@ interface NoteGridProps { onNotePress: (note: Note) => void; onRefresh: () => void; hasActiveFilter?: boolean; + draggable?: boolean; + onReorder?: (newIds: string[]) => void; } export const NoteGrid: React.FC = ({ @@ -26,6 +29,8 @@ export const NoteGrid: React.FC = ({ onNotePress, onRefresh, hasActiveFilter = false, + draggable = false, + onReorder, }) => { const insets = useSafeAreaInsets(); @@ -63,6 +68,18 @@ export const NoteGrid: React.FC = ({ ); } + if (draggable && onReorder) { + return ( + + ); + } + return ( Promise; setSearchQuery: (query: string) => void; setSort: (sort: NoteSort) => void; + setManualOrder: (ids: string[]) => void; } const NotesContext = createContext(undefined); @@ -47,14 +50,20 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ loading: false, searchQuery: '', sort: 'date_desc', + manualOrder: [], }); - // Restore persisted sort on mount + // Restore persisted sort and manual order on mount useEffect(() => { - AsyncStorage.getItem(SORT_KEY).then((saved) => { - if (saved) { - setState((prev) => ({ ...prev, sort: saved as NoteSort })); - } + Promise.all([ + AsyncStorage.getItem(SORT_KEY), + AsyncStorage.getItem(MANUAL_ORDER_KEY), + ]).then(([savedSort, savedOrder]) => { + setState((prev) => ({ + ...prev, + ...(savedSort ? { sort: savedSort as NoteSort } : {}), + ...(savedOrder ? { manualOrder: JSON.parse(savedOrder) as string[] } : {}), + })); }).catch(() => {}); }, []); @@ -174,16 +183,33 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ AsyncStorage.setItem(SORT_KEY, sort).catch(() => {}); }, []); + const setManualOrder = useCallback((ids: string[]) => { + setState((prev) => ({ ...prev, manualOrder: ids })); + AsyncStorage.setItem(MANUAL_ORDER_KEY, JSON.stringify(ids)).catch(() => {}); + }, []); + const filteredNotes = useMemo(() => { - if (!state.searchQuery.trim()) return state.notes; - const q = state.searchQuery.toLowerCase(); - return state.notes.filter( - (note) => - note.title.toLowerCase().includes(q) || - note.formatted_text.toLowerCase().includes(q) || - note.raw_transcription?.toLowerCase().includes(q) - ); - }, [state.notes, state.searchQuery]); + const base = (() => { + if (!state.searchQuery.trim()) return state.notes; + const q = state.searchQuery.toLowerCase(); + return state.notes.filter( + (note) => + note.title.toLowerCase().includes(q) || + note.formatted_text.toLowerCase().includes(q) || + note.raw_transcription?.toLowerCase().includes(q) + ); + })(); + + if (state.sort !== 'manual' || state.manualOrder.length === 0) return base; + + // Apply manual order: known IDs first (in saved order), then new notes appended + const orderMap = new Map(state.manualOrder.map((id, i) => [id, i])); + const known = base + .filter((n) => orderMap.has(n.id)) + .sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); + const newNotes = base.filter((n) => !orderMap.has(n.id)); + return [...known, ...newNotes]; + }, [state.notes, state.searchQuery, state.sort, state.manualOrder]); return ( = ({ deleteNotePermanently, setSearchQuery, setSort, + setManualOrder, }} > {children} diff --git a/services/notes.ts b/services/notes.ts index af810d1..42d1455 100644 --- a/services/notes.ts +++ b/services/notes.ts @@ -18,6 +18,7 @@ export const fetchNotes = async (sort: NoteSort = 'date_desc'): Promise case 'title_desc': query = query.order('title', { ascending: false }).order('id', { ascending: true }); break; + case 'manual': case 'date_desc': default: query = query.order('created_at', { ascending: false }).order('id', { ascending: true }); diff --git a/types/index.ts b/types/index.ts index a76b905..d274400 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,6 +1,6 @@ export type FormatType = 'bullet_list' | 'paragraph' | 'action_items' | 'meeting_notes' | 'custom'; -export type NoteSort = 'date_desc' | 'date_asc' | 'title_asc' | 'title_desc'; +export type NoteSort = 'date_desc' | 'date_asc' | 'title_asc' | 'title_desc' | 'manual'; export type RecordingStatus = | 'pending' // audio saved locally, not yet transcribed From d4412e5d148a641bea9c338f043d1b8e26e90618 Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Thu, 2 Apr 2026 06:29:44 +0200 Subject: [PATCH 13/17] feat: long-press copy, 1-tag AI limit, tag picker on text note creation - Long press a note card to copy title + content to clipboard; card highlights with a 'Copied!' badge for 1.5 s (expo-clipboard) - AI auto-tagging limited to 1 tag per note (was up to 3); manual tagging remains unlimited - Text note creation screen now shows a tag picker at the bottom; selected tags are applied on save Co-Authored-By: Claude Sonnet 4.6 --- app/note-create.tsx | 60 ++++++++++++- components/NoteCard.tsx | 38 +++++++- package-lock.json | 89 +++++++++++++++++++ package.json | 2 + supabase/functions/process-recording/index.ts | 4 +- 5 files changed, 187 insertions(+), 6 deletions(-) diff --git a/app/note-create.tsx b/app/note-create.tsx index a408b02..4512985 100644 --- a/app/note-create.tsx +++ b/app/note-create.tsx @@ -15,18 +15,28 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Colors } from '../constants/colors'; import { createNote } from '../services/notes'; import { useNotes } from '../contexts/NotesContext'; +import { useTags } from '../contexts/TagsContext'; +import { TagChip } from '../components/TagChip'; import { showAlert } from '../utils/alert'; export default function NoteCreateScreen() { const router = useRouter(); const { fetchNotes } = useNotes(); + const { tags, addTagToNote } = useTags(); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [saving, setSaving] = useState(false); + const [selectedTagIds, setSelectedTagIds] = useState([]); const contentRef = useRef(null); + const toggleTag = (tagId: string) => { + setSelectedTagIds((prev) => + prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId] + ); + }; + const handleSave = async () => { const trimmedTitle = title.trim(); const trimmedContent = content.trim(); @@ -38,13 +48,14 @@ export default function NoteCreateScreen() { setSaving(true); try { - await createNote({ + const note = await createNote({ title: trimmedTitle || 'Untitled note', formatted_text: trimmedContent, raw_transcription: null, format_type: 'paragraph', source: 'text', }); + await Promise.all(selectedTagIds.map((tagId) => addTagToNote(note.id, tagId).catch(() => {}))); await fetchNotes(); router.back(); } catch (err: any) { @@ -116,6 +127,26 @@ export default function NoteCreateScreen() { textAlignVertical="top" autoFocus /> + + {tags.length > 0 && ( + + Tags + + {tags.map((tag) => { + const selected = selectedTagIds.includes(tag.id); + return ( + toggleTag(tag.id)} + style={[styles.tagPill, selected && styles.tagPillSelected]} + > + + + ); + })} + + + )} @@ -188,7 +219,32 @@ const styles = StyleSheet.create({ fontSize: 16, color: Colors.text, lineHeight: 24, - minHeight: 300, + minHeight: 200, padding: 0, }, + tagSection: { + marginTop: 24, + borderTopWidth: 1, + borderTopColor: Colors.border, + paddingTop: 16, + }, + tagLabel: { + fontSize: 13, + fontWeight: '600', + color: Colors.textTertiary, + marginBottom: 10, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + tagRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + tagPill: { + opacity: 0.45, + }, + tagPillSelected: { + opacity: 1, + }, }); diff --git a/components/NoteCard.tsx b/components/NoteCard.tsx index 6fe0416..be86474 100644 --- a/components/NoteCard.tsx +++ b/components/NoteCard.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useState } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; import { Note } from '../types'; import { TagChip } from './TagChip'; import { Colors } from '../constants/colors'; @@ -14,11 +15,21 @@ interface NoteCardProps { export const NoteCard: React.FC = ({ note, onPress }) => { const { noteTagsMap } = useTags(); const noteTags = (noteTagsMap[note.id] ?? []).slice(0, 3); + const [copied, setCopied] = useState(false); + + const handleLongPress = async () => { + const text = note.title ? `${note.title}\n\n${note.formatted_text}` : note.formatted_text; + await Clipboard.setStringAsync(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; return ( [styles.card, pressed && styles.cardPressed]} + style={({ pressed }) => [styles.card, pressed && styles.cardPressed, copied && styles.cardCopied]} onPress={() => onPress(note)} + onLongPress={handleLongPress} + delayLongPress={500} accessibilityRole="button" accessibilityLabel={`Note: ${note.title}`} > @@ -38,6 +49,11 @@ export const NoteCard: React.FC = ({ note, onPress }) => { {truncateText(note.formatted_text, 180)} + {copied && ( + + Copied! + + )} ); }; @@ -54,6 +70,24 @@ const styles = StyleSheet.create({ backgroundColor: Colors.surfaceHover, transform: [{ scale: 0.98 }], }, + cardCopied: { + borderColor: Colors.primary, + borderWidth: 1.5, + }, + copiedBadge: { + position: 'absolute', + top: 10, + right: 12, + backgroundColor: Colors.primary, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 4, + }, + copiedText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + }, header: { flexDirection: 'row', alignItems: 'center', diff --git a/package-lock.json b/package-lock.json index b45b0d5..438ac05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@supabase/supabase-js": "^2.45.0", "expo": "~54.0.0", "expo-audio": "~1.1.1", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-file-system": "~18.0.12", "expo-keep-awake": "~15.0.8", @@ -20,6 +21,7 @@ "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-updates": "~29.0.16", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", @@ -5777,6 +5779,17 @@ "react-native": "*" } }, + "node_modules/expo-clipboard": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz", + "integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -5791,6 +5804,12 @@ "react-native": "*" } }, + "node_modules/expo-eas-client": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz", + "integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==", + "license": "MIT" + }, "node_modules/expo-file-system": { "version": "18.0.12", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz", @@ -5818,6 +5837,12 @@ "react-native": "*" } }, + "node_modules/expo-json-utils": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", + "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", + "license": "MIT" + }, "node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -5842,6 +5867,19 @@ "react-native": "*" } }, + "node_modules/expo-manifests": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz", + "integrity": "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.11", + "expo-json-utils": "~0.15.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.24", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", @@ -5985,6 +6023,57 @@ "react-native": "*" } }, + "node_modules/expo-structured-headers": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz", + "integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==", + "license": "MIT" + }, + "node_modules/expo-updates": { + "version": "29.0.16", + "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.16.tgz", + "integrity": "sha512-E9/fxRz/Eurtc7hxeI/6ZPyHH3To9Xoccm1kXoICZTRojmuTo+dx0Xv53UHyHn4G5zGMezyaKF2Qtj3AKcT93w==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/plist": "^0.4.8", + "@expo/spawn-async": "^1.7.2", + "arg": "4.1.0", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "expo-eas-client": "~1.0.8", + "expo-manifests": "~1.0.10", + "expo-structured-headers": "~5.0.0", + "expo-updates-interface": "~2.0.0", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "resolve-from": "^5.0.0" + }, + "bin": { + "expo-updates": "bin/cli.js" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-updates-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz", + "integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-updates/node_modules/arg": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", + "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==", + "license": "MIT" + }, "node_modules/expo/node_modules/@expo/cli": { "version": "54.0.23", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", diff --git a/package.json b/package.json index 31c99f0..cf962e3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@supabase/supabase-js": "^2.45.0", "expo": "~54.0.0", "expo-audio": "~1.1.1", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-file-system": "~18.0.12", "expo-keep-awake": "~15.0.8", @@ -43,6 +44,7 @@ "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-updates": "~29.0.16", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", diff --git a/supabase/functions/process-recording/index.ts b/supabase/functions/process-recording/index.ts index b981ec1..14b3921 100644 --- a/supabase/functions/process-recording/index.ts +++ b/supabase/functions/process-recording/index.ts @@ -245,7 +245,7 @@ async function suggestTags( { role: 'system', content: - 'Given this note content, select 0 to 3 relevant tags from the provided list. Only use tags from the list. If no tags are relevant, return an empty array.', + 'Given this note content, select at most 1 relevant tag from the provided list. Only use tags from the list. If no tags are relevant, return an empty array.', }, { role: 'user', @@ -262,7 +262,7 @@ async function suggestTags( tags: { type: 'array', items: { type: 'string' }, - maxItems: 3, + maxItems: 1, }, }, required: ['tags'], From dcdd9f49b563c9e1d79fb811da101598b7c2fb92 Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Thu, 2 Apr 2026 06:43:59 +0200 Subject: [PATCH 14/17] fix: crash when selecting manual sort due to undefined manualOrder The session reset setState was missing manualOrder, causing it to be undefined. Added defensive fallback in filteredNotes useMemo as well. Co-Authored-By: Claude Sonnet 4.6 --- contexts/NotesContext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contexts/NotesContext.tsx b/contexts/NotesContext.tsx index cfd9cf4..bb3e818 100644 --- a/contexts/NotesContext.tsx +++ b/contexts/NotesContext.tsx @@ -83,7 +83,7 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ if (session) { fetchNotes(); } else { - setState({ notes: [], loading: false, searchQuery: '', sort: 'date_desc' }); + setState({ notes: [], loading: false, searchQuery: '', sort: 'date_desc', manualOrder: [] }); } }, [session, fetchNotes]); @@ -200,7 +200,7 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ ); })(); - if (state.sort !== 'manual' || state.manualOrder.length === 0) return base; + if (state.sort !== 'manual' || (state.manualOrder ?? []).length === 0) return base; // Apply manual order: known IDs first (in saved order), then new notes appended const orderMap = new Map(state.manualOrder.map((id, i) => [id, i])); From b45ab82cf3c7fc28f048cbada515aa4f23ff487f Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Thu, 2 Apr 2026 06:49:25 +0200 Subject: [PATCH 15/17] fix: prevent page refresh on drag, make manual order the default sort - Use React state for scrollEnabled instead of setNativeProps (works on web) - Add Capture variants of PanResponder handlers to intercept events before browser scroll takes over - Default sort changed from date_desc to manual; persisted via AsyncStorage Co-Authored-By: Claude Sonnet 4.6 --- components/DraggableNoteList.tsx | 11 ++++++++--- contexts/NotesContext.tsx | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/components/DraggableNoteList.tsx b/components/DraggableNoteList.tsx index 3d6f898..b1c3804 100644 --- a/components/DraggableNoteList.tsx +++ b/components/DraggableNoteList.tsx @@ -36,6 +36,7 @@ export const DraggableNoteList: React.FC = ({ // Active drag state const [draggingIndex, setDraggingIndex] = useState(null); + const [scrollEnabled, setScrollEnabled] = useState(true); const dragTranslateY = useRef(new Animated.Value(0)).current; const activeDeltaY = useRef(0); @@ -64,11 +65,14 @@ export const DraggableNoteList: React.FC = ({ (noteIndex: number) => PanResponder.create({ onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, onPanResponderGrant: () => { dragTranslateY.setValue(0); activeDeltaY.current = 0; setDraggingIndex(noteIndex); - scrollRef.current?.setNativeProps({ scrollEnabled: false }); + setScrollEnabled(false); }, onPanResponderMove: (_, gesture) => { activeDeltaY.current = gesture.dy; @@ -87,12 +91,12 @@ export const DraggableNoteList: React.FC = ({ dragTranslateY.setValue(0); setDraggingIndex(null); - scrollRef.current?.setNativeProps({ scrollEnabled: true }); + setScrollEnabled(true); }, onPanResponderTerminate: () => { dragTranslateY.setValue(0); setDraggingIndex(null); - scrollRef.current?.setNativeProps({ scrollEnabled: true }); + setScrollEnabled(true); }, }), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -102,6 +106,7 @@ export const DraggableNoteList: React.FC = ({ return ( { scrollOffset.current = e.nativeEvent.contentOffset.y; }} scrollEventThrottle={16} diff --git a/contexts/NotesContext.tsx b/contexts/NotesContext.tsx index bb3e818..a2e171d 100644 --- a/contexts/NotesContext.tsx +++ b/contexts/NotesContext.tsx @@ -49,7 +49,7 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ notes: [], loading: false, searchQuery: '', - sort: 'date_desc', + sort: 'manual', manualOrder: [], }); @@ -83,7 +83,7 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ if (session) { fetchNotes(); } else { - setState({ notes: [], loading: false, searchQuery: '', sort: 'date_desc', manualOrder: [] }); + setState({ notes: [], loading: false, searchQuery: '', sort: 'manual', manualOrder: [] }); } }, [session, fetchNotes]); From 0873a7efd053170ce40e74396c206b998fdb1ba8 Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Thu, 2 Apr 2026 06:54:37 +0200 Subject: [PATCH 16/17] feat: reorder mode toggle, fix pull-to-refresh conflict on drag - Drag handles only shown when reorder mode is active (toggle button in header, only visible when sort is Manual order) - Toggle button highlights with primary color when active - Removing RefreshControl from DraggableNoteList and adding bounces=false + overScrollMode=never to prevent pull-to-refresh triggering during drag Co-Authored-By: Claude Sonnet 4.6 --- app/index.tsx | 39 ++++++++++++++++++++++++++++++++ components/DraggableNoteList.tsx | 30 ++++++++++++------------ components/NoteGrid.tsx | 3 +++ 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index bb31c35..5a93237 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -31,6 +31,7 @@ export default function HomeScreen() { const [selectedTagId, setSelectedTagId] = useState(null); const [tagNoteIds, setTagNoteIds] = useState(null); const [showSortPicker, setShowSortPicker] = useState(false); + const [reorderMode, setReorderMode] = useState(false); // Apply default tag filter on first load useEffect(() => { @@ -58,6 +59,7 @@ export default function HomeScreen() { const handleSortChange = (newSort: NoteSort) => { setSort(newSort); setShowSortPicker(false); + if (newSort !== 'manual') setReorderMode(false); }; const displayedNotes = useMemo(() => { @@ -113,6 +115,23 @@ export default function HomeScreen() { + {sort === 'manual' && ( + setReorderMode((v) => !v)} + style={({ pressed }) => [ + styles.iconButton, + reorderMode && styles.iconButtonActive, + pressed && styles.iconButtonPressed, + ]} + accessibilityLabel="Toggle reorder mode" + > + + + + + + + )} [ @@ -160,6 +179,7 @@ export default function HomeScreen() { onRefresh={handleRefresh} hasActiveFilter={selectedTagId !== null} draggable={sort === 'manual'} + reorderMode={reorderMode} onReorder={setManualOrder} /> @@ -265,6 +285,25 @@ const styles = StyleSheet.create({ backgroundColor: Colors.surfaceHover, transform: [{ scale: 0.95 }], }, + iconButtonActive: { + backgroundColor: Colors.primarySubtle, + borderWidth: 1.5, + borderColor: Colors.primary, + }, + reorderIconContainer: { + width: 20, + height: 20, + justifyContent: 'center', + gap: 3, + }, + reorderBar: { + height: 2.5, + borderRadius: 1.5, + backgroundColor: Colors.textSecondary, + }, + reorderBarActive: { + backgroundColor: Colors.primary, + }, recordingsIconContainer: { width: 20, height: 20, diff --git a/components/DraggableNoteList.tsx b/components/DraggableNoteList.tsx index b1c3804..c2d6dc9 100644 --- a/components/DraggableNoteList.tsx +++ b/components/DraggableNoteList.tsx @@ -5,7 +5,6 @@ import { PanResponder, Animated, StyleSheet, - RefreshControl, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Note } from '../types'; @@ -18,6 +17,7 @@ interface DraggableNoteListProps { onReorder: (newIds: string[]) => void; onRefresh: () => void; loading: boolean; + reorderMode?: boolean; } export const DraggableNoteList: React.FC = ({ @@ -26,6 +26,7 @@ export const DraggableNoteList: React.FC = ({ onReorder, onRefresh, loading, + reorderMode = false, }) => { const insets = useSafeAreaInsets(); const scrollRef = useRef(null); @@ -107,17 +108,12 @@ export const DraggableNoteList: React.FC = ({ { scrollOffset.current = e.nativeEvent.contentOffset.y; }} scrollEventThrottle={16} contentContainerStyle={[styles.content, { paddingBottom: 120 + insets.bottom }]} - refreshControl={ - - } > {notes.map((note, index) => { const isDragging = draggingIndex === index; @@ -146,14 +142,16 @@ export const DraggableNoteList: React.FC = ({ - - - - - + {reorderMode && ( + + + + + + )} diff --git a/components/NoteGrid.tsx b/components/NoteGrid.tsx index 41bbb1e..71f4115 100644 --- a/components/NoteGrid.tsx +++ b/components/NoteGrid.tsx @@ -20,6 +20,7 @@ interface NoteGridProps { onRefresh: () => void; hasActiveFilter?: boolean; draggable?: boolean; + reorderMode?: boolean; onReorder?: (newIds: string[]) => void; } @@ -30,6 +31,7 @@ export const NoteGrid: React.FC = ({ onRefresh, hasActiveFilter = false, draggable = false, + reorderMode = false, onReorder, }) => { const insets = useSafeAreaInsets(); @@ -76,6 +78,7 @@ export const NoteGrid: React.FC = ({ onReorder={onReorder} onRefresh={onRefresh} loading={loading} + reorderMode={reorderMode} /> ); } From c440f16f64e3a344538f1721e83450a9c564681a Mon Sep 17 00:00:00 2001 From: Louis Fontaine Date: Thu, 2 Apr 2026 07:13:35 +0200 Subject: [PATCH 17/17] fix: replace recordings icon with waveform to distinguish from reorder button Co-Authored-By: Claude Sonnet 4.6 --- app/index.tsx | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index 5a93237..945ec9b 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -140,10 +140,12 @@ export default function HomeScreen() { ]} accessibilityLabel="Recordings" > - - - - + + + + + +