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/index.tsx b/app/index.tsx
index 0a76a77..945ec9b 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,27 +1,48 @@
-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' },
+ { value: 'manual', label: 'Manual order' },
+];
+
export default function HomeScreen() {
const router = useRouter();
- const { filteredNotes, loading, searchQuery, setSearchQuery, fetchNotes } =
+ const { filteredNotes, loading, searchQuery, setSearchQuery, fetchNotes, sort, setSort, setManualOrder } =
useNotes();
const { tags, refreshNoteTagsMap } = useTags();
+ const { defaultTagId } = usePreferences();
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(() => {
+ 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 +54,12 @@ export default function HomeScreen() {
setTagNoteIds([]);
}
}
+ }, []);
+
+ const handleSortChange = (newSort: NoteSort) => {
+ setSort(newSort);
+ setShowSortPicker(false);
+ if (newSort !== 'manual') setReorderMode(false);
};
const displayedNotes = useMemo(() => {
@@ -64,45 +91,77 @@ export default function HomeScreen() {
router.push('/recordings');
};
+ const currentSortLabel = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? 'Sort';
+
return (
-
+
VoiceKeeper
-
- {displayedNotes.length > 0
- ? `${displayedNotes.length} note${displayedNotes.length !== 1 ? 's' : ''}`
- : 'No notes yet'}
-
+ setShowSortPicker(true)}
+ style={({ pressed }) => [styles.subtitleRow, pressed && { opacity: 0.6 }]}
+ accessibilityLabel="Sort notes"
+ >
+
+ {displayedNotes.length > 0
+ ? `${displayedNotes.length} note${displayedNotes.length !== 1 ? 's' : ''}`
+ : 'No notes yet'}
+
+ Β· β {currentSortLabel}
+
+ {sort === 'manual' && (
+ setReorderMode((v) => !v)}
+ style={({ pressed }) => [
+ styles.iconButton,
+ reorderMode && styles.iconButtonActive,
+ pressed && styles.iconButtonPressed,
+ ]}
+ accessibilityLabel="Toggle reorder mode"
+ >
+
+
+
+
+
+
+ )}
[
- 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"
+ >
+
+
+
+
+
+
@@ -121,10 +180,43 @@ export default function HomeScreen() {
onNotePress={handleNotePress}
onRefresh={handleRefresh}
hasActiveFilter={selectedTagId !== null}
+ draggable={sort === 'manual'}
+ reorderMode={reorderMode}
+ onReorder={setManualOrder}
/>
+
+ {/* 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 && β}
+
+ ))}
+
+
+
);
}
@@ -163,24 +255,26 @@ 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,
},
- recordingsButtonText: {
- fontSize: 14,
- fontWeight: '600',
+ sortIndicator: {
+ fontSize: 13,
color: Colors.primary,
+ fontWeight: '600',
+ },
+ headerActions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
},
- settingsButton: {
+ iconButton: {
width: 44,
height: 44,
borderRadius: 22,
@@ -189,25 +283,95 @@ const styles = StyleSheet.create({
alignItems: 'center',
...Colors.shadow.sm,
},
- settingsButtonPressed: {
+ iconButtonPressed: {
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,
+ },
+ waveformContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 2.5,
+ height: 20,
+ },
+ waveBar: {
+ width: 2.5,
+ borderRadius: 1.5,
+ backgroundColor: 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',
+ },
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,
},
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/app/note/[id].tsx b/app/note/[id].tsx
index 70ce604..70ce2e8 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';
@@ -21,15 +24,38 @@ 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';
// 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');
+ // 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;
@@ -40,28 +66,81 @@ function ActionItemsList({ text, onTextChange }: { text: string; onTextChange?:
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; }
+ const newLine = `- [ ] ${trimmed}`;
+ const updated = text.trimEnd() + '\n' + newLine;
+ onTextChange?.(updated);
+ setNewItemText('');
+ 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}
-
+ {otherLines.map((item) => (
+ {item.line}
+ ))}
+ {uncheckedItems.map(renderItem)}
+ {checkedItems.length > 0 && (
+ <>
+
+ {checkedItems.map(renderItem)}
+ >
+ )}
+ {allowAdd && (
+ showInput ? (
+
+
+
+ Add
- );
- }
- if (!line.trim()) return null;
- return {line};
- })}
+ { setShowInput(false); setNewItemText(''); }} style={actionStyles.addCancel}>
+ β
+
+
+ ) : (
+ setShowInput(true)}>
+ + Add item
+
+ )
+ )}
);
}
@@ -105,6 +184,66 @@ 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,
+ 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 +256,43 @@ export default function NoteDetailScreen() {
const note = useMemo(() => notes.find((n) => n.id === id), [notes, id]);
- const [isEditing, setIsEditing] = useState(false);
+ // 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');
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);
+ setIsEditing(note.format_type !== 'action_items');
+ }
+ }, [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,17 +376,24 @@ export default function NoteDetailScreen() {
setShowFormatPicker(false);
setReformatInstructions('');
setSelectedFormat(null);
+ // Dismiss keyboard
+ titleInputRef.current?.blur();
+ contentInputRef.current?.blur();
};
const handleReformat = async (formatType: FormatType) => {
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);
@@ -272,6 +438,11 @@ export default function NoteDetailScreen() {
return (
+
router.back()}
@@ -322,12 +493,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 +534,10 @@ export default function NoteDetailScreen() {
@@ -380,8 +557,11 @@ export default function NoteDetailScreen() {
placeholderTextColor={Colors.textTertiary}
selectTextOnFocus
returnKeyType="next"
+ multiline
+ onSubmitEditing={() => contentInputRef.current?.focus()}
/>
{/* Inline format picker */}
@@ -447,6 +628,7 @@ export default function NoteDetailScreen() {
updateNote(note.id, { formatted_text: updated })}
+ allowAdd
/>
) : (
{note.formatted_text}
@@ -501,6 +683,7 @@ export default function NoteDetailScreen() {
onToggle={handleToggleTag}
onCreateTag={handleCreateTag}
/>
+
);
}
@@ -566,6 +749,9 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: Colors.background,
},
+ keyboardAvoid: {
+ flex: 1,
+ },
centered: {
flex: 1,
justifyContent: 'center',
@@ -633,6 +819,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 +869,7 @@ const styles = StyleSheet.create({
borderBottomColor: Colors.primary,
paddingBottom: 10,
letterSpacing: -0.3,
+ lineHeight: 34,
},
textInput: {
fontSize: 16,
diff --git a/app/recording/[id].tsx b/app/recording/[id].tsx
index 49a61bf..c8f60db 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,18 @@ 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 existingTermsForTranscribe = await loadValidatedTerms();
+ const { transcription: rawTranscription, uncertainTerms } =
+ 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
+ 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 +116,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 +169,25 @@ 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 existingTermsForTranscribe = await loadValidatedTerms();
+ const { transcription: rawTranscription, uncertainTerms } =
+ await transcribeRecording(recording.localUri, existingTermsForTranscribe.length > 0 ? existingTermsForTranscribe : undefined);
+ 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 +203,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 +233,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 +328,12 @@ export default function RecordingDetailScreen() {
return (
+
{/* Top bar */}
router.back()} style={styles.topButton}>
diff --git a/app/settings.tsx b/app/settings.tsx
index 5497953..7beefb2 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();
@@ -401,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 */}
@@ -455,6 +473,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 */}
) => void;
+ onSkip: () => void;
+}
+
+export const AcronymValidationModal: React.FC = ({
+ visible,
+ terms,
+ onConfirm,
+ onSkip,
+}) => {
+ const [entries, setEntries] = useState([]);
+
+ useEffect(() => {
+ const seen = new Set();
+ setEntries(
+ 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]);
+
+ 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/components/DraggableNoteList.tsx b/components/DraggableNoteList.tsx
new file mode 100644
index 0000000..c2d6dc9
--- /dev/null
+++ b/components/DraggableNoteList.tsx
@@ -0,0 +1,199 @@
+import React, { useRef, useState, useCallback } from 'react';
+import {
+ View,
+ ScrollView,
+ PanResponder,
+ Animated,
+ StyleSheet,
+} 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;
+ reorderMode?: boolean;
+}
+
+export const DraggableNoteList: React.FC = ({
+ notes,
+ onNotePress,
+ onReorder,
+ onRefresh,
+ loading,
+ reorderMode = false,
+}) => {
+ 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 [scrollEnabled, setScrollEnabled] = useState(true);
+ 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,
+ onStartShouldSetPanResponderCapture: () => true,
+ onMoveShouldSetPanResponder: () => true,
+ onMoveShouldSetPanResponderCapture: () => true,
+ onPanResponderGrant: () => {
+ dragTranslateY.setValue(0);
+ activeDeltaY.current = 0;
+ setDraggingIndex(noteIndex);
+ setScrollEnabled(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);
+ setScrollEnabled(true);
+ },
+ onPanResponderTerminate: () => {
+ dragTranslateY.setValue(0);
+ setDraggingIndex(null);
+ setScrollEnabled(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 }]}
+ >
+ {notes.map((note, index) => {
+ const isDragging = draggingIndex === index;
+ const panResponder = createPanResponder(index);
+
+ return (
+ {
+ itemHeights.current[index] = e.nativeEvent.layout.height;
+ }}
+ style={styles.itemWrapper}
+ >
+
+
+
+
+
+ {reorderMode && (
+
+
+
+
+
+ )}
+
+
+
+ );
+ })}
+
+ );
+};
+
+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/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/components/NoteGrid.tsx b/components/NoteGrid.tsx
index fd4a45c..71f4115 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,9 @@ interface NoteGridProps {
onNotePress: (note: Note) => void;
onRefresh: () => void;
hasActiveFilter?: boolean;
+ draggable?: boolean;
+ reorderMode?: boolean;
+ onReorder?: (newIds: string[]) => void;
}
export const NoteGrid: React.FC = ({
@@ -26,6 +30,9 @@ export const NoteGrid: React.FC = ({
onNotePress,
onRefresh,
hasActiveFilter = false,
+ draggable = false,
+ reorderMode = false,
+ onReorder,
}) => {
const insets = useSafeAreaInsets();
@@ -63,6 +70,19 @@ export const NoteGrid: React.FC = ({
);
}
+ if (draggable && onReorder) {
+ return (
+
+ );
+ }
+
return (
Promise;
deleteNotePermanently: (id: string) => Promise;
setSearchQuery: (query: string) => void;
+ setSort: (sort: NoteSort) => void;
+ setManualOrder: (ids: string[]) => void;
}
const NotesContext = createContext(undefined);
@@ -41,24 +49,41 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({
notes: [],
loading: false,
searchQuery: '',
+ sort: 'manual',
+ manualOrder: [],
});
+ // Restore persisted sort and manual order on mount
+ useEffect(() => {
+ 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(() => {});
+ }, []);
+
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: 'manual', manualOrder: [] });
}
}, [session, fetchNotes]);
@@ -153,16 +178,38 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({
setState((prev) => ({ ...prev, searchQuery: query }));
}, []);
+ const setSort = useCallback((sort: NoteSort) => {
+ setState((prev) => ({ ...prev, sort }));
+ 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 (
= ({
restoreNote,
deleteNotePermanently,
setSearchQuery,
+ setSort,
+ setManualOrder,
}}
>
{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/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/services/notes.ts b/services/notes.ts
index 8085172..42d1455 100644
--- a/services/notes.ts
+++ b/services/notes.ts
@@ -1,13 +1,30 @@
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 }).order('id', { ascending: true });
+ break;
+ case 'title_asc':
+ query = query.order('title', { ascending: true }).order('id', { ascending: true });
+ break;
+ 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 });
+ }
+
+ 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/services/processing.ts b/services/processing.ts
index e037ff8..4388f9b 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)
@@ -17,8 +22,9 @@ function sliceBlob(blob: Blob): Blob[] {
}
export const transcribeRecording = async (
- localUri: string
-): Promise => {
+ 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');
@@ -28,7 +34,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,9 +53,14 @@ 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[]) ?? [],
+ };
};
+ const knownTermsJson = knownTerms && knownTerms.length > 0 ? JSON.stringify(knownTerms) : null;
+
if (Platform.OS === 'web') {
const response = await fetch(localUri);
const blob = await response.blob();
@@ -58,19 +69,22 @@ 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);
}
// 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
@@ -81,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);
}
@@ -111,9 +126,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 +142,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 +166,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 +202,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 +217,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..1ae3ed4
--- /dev/null
+++ b/services/validatedTerms.ts
@@ -0,0 +1,49 @@
+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;
+};
+
+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;
+};
diff --git a/supabase/functions/process-recording/index.ts b/supabase/functions/process-recording/index.ts
index fb25208..14b3921 100644
--- a/supabase/functions/process-recording/index.ts
+++ b/supabase/functions/process-recording/index.ts
@@ -59,7 +59,81 @@ 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,
+ 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: {
+ 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: systemContent,
+ },
+ { role: 'user', content: transcription },
+ ],
+ response_format: {
+ type: 'json_schema',
+ json_schema: {
+ name: 'uncertain_terms',
+ strict: true,
+ schema: {
+ type: 'object',
+ properties: {
+ uncertain_terms: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ original: { type: 'string' },
+ suggestion: { anyOf: [{ type: 'string' }, { type: 'null' }] },
+ },
+ required: ['original', 'suggestion'],
+ additionalProperties: false,
+ },
+ },
+ },
+ required: ['uncertain_terms'],
+ additionalProperties: false,
+ },
+ },
+ },
+ }),
+ });
+ if (!response.ok) return [];
+ const result = await response.json();
+ const content = result.choices[0].message.content;
+ const parsed = typeof content === 'string' ? JSON.parse(content) : 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 +142,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 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\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)
if (customInstructions && customInstructions.trim()) {
prompt += `\n\nAdditional user instructions (follow these closely): ${customInstructions.trim()}`;
@@ -111,9 +193,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',
@@ -162,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',
@@ -179,7 +262,7 @@ async function suggestTags(
tags: {
type: 'array',
items: { type: 'string' },
- maxItems: 3,
+ maxItems: 1,
},
},
required: ['tags'],
@@ -287,9 +370,16 @@ 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, knownTerms.length > 0 ? knownTerms : undefined);
return new Response(
- JSON.stringify({ transcription }),
+ JSON.stringify({ transcription, uncertain_terms: uncertainTerms }),
{ status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } }
);
}
@@ -324,11 +414,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/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;
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);
diff --git a/types/index.ts b/types/index.ts
index aad2d47..d274400 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' | 'manual';
+
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;
}