Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
bdcad84
SANDCASTLE: deepen data source local app data seam
justinpbarnett Apr 27, 2026
f0d31f8
SANDCASTLE: Extract reference card layout model for issue #3
justinpbarnett Apr 27, 2026
c14fcf9
SANDCASTLE: Extract session runtime for issue #1
justinpbarnett Apr 27, 2026
0502a24
Refine data source app data settings seam
justinpbarnett Apr 27, 2026
1b7ac85
Refine reference card layout settings
justinpbarnett Apr 27, 2026
6a0c420
Refine session runtime extraction
justinpbarnett Apr 27, 2026
b98af47
Merge branch 'sandcastle/issue-2-deepen-local-app-data-for-data-sourc…
justinpbarnett Apr 27, 2026
a5385c8
Merge branch 'sandcastle/issue-3-extract-reference-card-layout-model'
justinpbarnett Apr 27, 2026
206b598
Merge Sandcastle issue branches
justinpbarnett Apr 27, 2026
cf585e0
SANDCASTLE: Extract entity card presentation derivation
justinpbarnett Apr 27, 2026
8fefbb8
SANDCASTLE: Migrate uploads and delete-all through app data
justinpbarnett Apr 27, 2026
755778b
SANDCASTLE: Deepen Voice settings category end-to-end
justinpbarnett Apr 27, 2026
e1ffeb3
Refine entity card presentation model
justinpbarnett Apr 27, 2026
7c8502c
Refine app data storage helpers
justinpbarnett Apr 27, 2026
eaae7bc
SANDCASTLE: Move session lifecycle into runtime
justinpbarnett Apr 27, 2026
41c69b2
Refine voice settings category handling
justinpbarnett Apr 27, 2026
76b4b59
Refine session runtime lifecycle cleanup
justinpbarnett Apr 27, 2026
a0fe171
Merge branch 'sandcastle/issue-5-migrate-uploads-and-delete-all-throu…
justinpbarnett Apr 27, 2026
6c8c143
Merge branch 'sandcastle/issue-6-extract-entity-card-presentation-der…
justinpbarnett Apr 27, 2026
503a0a8
Merge Sandcastle issue branches 4, 5, 6, and 9
justinpbarnett Apr 27, 2026
3f6a334
SANDCASTLE: Deepen Files settings category end-to-end
justinpbarnett Apr 27, 2026
17f0389
SANDCASTLE: Make voice capture late-event-safe
justinpbarnett Apr 27, 2026
000431d
Refine files settings controller
justinpbarnett Apr 27, 2026
380cb4d
Refine late-event-safe STT provider
justinpbarnett Apr 27, 2026
e4df4a5
Merge Sandcastle issue branches 7 and 8
justinpbarnett Apr 27, 2026
e0b310e
SANDCASTLE: Split Deepgram capture adapters
justinpbarnett Apr 27, 2026
4f50119
SANDCASTLE: Introduce shared upload ingestion
justinpbarnett Apr 27, 2026
b1a25cd
Refine Deepgram capture adapter split
justinpbarnett Apr 27, 2026
c7b0867
Refine shared ingestion helpers
justinpbarnett Apr 27, 2026
088d517
Merge Sandcastle issue branches 10 and 11
justinpbarnett Apr 27, 2026
8973670
SANDCASTLE: Migrate Google Docs ingestion
justinpbarnett Apr 27, 2026
ce14742
Refine Google Docs ingestion provider
justinpbarnett Apr 27, 2026
c38d01a
Merge Sandcastle issue branch 12
justinpbarnett Apr 27, 2026
ca16852
Ignore Sandcastle work directory
justinpbarnett Apr 27, 2026
1758d6c
Split app data storage modules
justinpbarnett Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ playwright-report/
npm-debug.*
yarn-debug.*
yarn-error.*

# Sandcastle agent work/logs
.sandcastle/
190 changes: 43 additions & 147 deletions app/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,24 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Platform, ScrollView, Text, TouchableOpacity, View, useWindowDimensions,
} from 'react-native';
import { ScrollView, Text, TouchableOpacity, View, useWindowDimensions } from 'react-native';

import { Ionicon } from '../src/components/Ionicon';
import { DEFAULT_DATA_SOURCES_SETTINGS, DataSourcesSettings, useDataSources } from '../src/context/data-sources';
import { createDefaultDataSourceSettings, type DataSourcesSettings, useDataSources } from '../src/context/data-sources';
import { useSession } from '../src/context/session';
import { useColors, useUISettings } from '../src/context/ui-settings';
import { parseWithAI } from '../src/entities/ai-parser';
import { UploadedFile, addUpload, getUploads, removeUpload, waitForUploadMutations } from '../src/entities/providers/file-upload';
import { Category, CATEGORIES } from '../src/settings/constants';
import { useFilesSettingsCategory } from '../src/settings/files-settings-category';
import { AISection } from '../src/settings/renderers/AISection';
import { DataSection } from '../src/settings/renderers/DataSection';
import { DisplaySection } from '../src/settings/renderers/DisplaySection';
import { FilesSection } from '../src/settings/renderers/FilesSection';
import { VoiceSection } from '../src/settings/renderers/VoiceSection';
import { createStyles } from '../src/settings/styles';
import { useVoiceSettingsCategory } from '../src/settings/voice-settings-category';
import {
createAppDataWriteToken,
getAppDataItem,
isAppDataWriteTokenCurrent,
resetStoredAppData,
setAppDataItem,
} from '../src/storage/app-data';
import { DEFAULT_STT_SETTINGS, STT_SETTINGS_KEY, STTSettings } from '../src/stt/index';

function confirmDeleteAllData(): Promise<boolean> {
const message = 'This deletes uploads, pasted content, AI parsed files, saved settings, API keys, source URLs, cached SRD data, and the current session on this device.';

if (Platform.OS === 'web' && typeof window !== 'undefined') {
return Promise.resolve(window.confirm(`Delete all local app data?\n\n${message}`));
}

return new Promise((resolve) => {
Alert.alert(
'Delete all local app data?',
message,
[
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
{ text: 'Delete All Data', style: 'destructive', onPress: () => resolve(true) },
],
{ cancelable: true, onDismiss: () => resolve(false) },
);
});
}

export default function SettingsScreen() {
const C = useColors();
Expand All @@ -52,59 +27,45 @@ export default function SettingsScreen() {
const styles = useMemo(() => createStyles(C, isWide), [C, isWide]);

const [category, setCategory] = useState<Category>('display');
const [sttSettings, setSttSettings] = useState<STTSettings>(DEFAULT_STT_SETTINGS);
const [voiceSaved, setVoiceSaved] = useState(false);
const [dataSaved, setDataSaved] = useState(false);
const [deleteAllPending, setDeleteAllPending] = useState(false);
const [deleteAllStatus, setDeleteAllStatus] = useState('');
const { cardSize, setCardSize, colorScheme, setColorScheme, resetUISettings } = useUISettings();
const { settings: ds, update: updateDs, bumpUploads, reset: resetDataSources } = useDataSources();
const { stop: stopSession } = useSession();
const {
sttSettings,
setSttSettings,
saveVoice,
voiceSaved,
isWebSpeech,
resetVoiceSettings,
} = useVoiceSettingsCategory();
const [dsLocal, setDsLocal] = useState<DataSourcesSettings>(ds);
const voiceSavedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dataSavedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

const [uploads, setUploads] = useState<UploadedFile[]>([]);
const [removingUploadId, setRemovingUploadId] = useState<string | null>(null);
const [pasteFileName, setPasteFileName] = useState('');
const [pasteContent, setPasteContent] = useState('');
const [aiContent, setAiContent] = useState('');
const [aiParsing, setAiParsing] = useState(false);
const [aiResult, setAiResult] = useState('');

const refreshUploads = useCallback(async () => {
setUploads(await getUploads());
bumpUploads();
}, [bumpUploads]);
const resetAfterDeleteAll = useCallback(() => {
resetDataSources();
resetUISettings();
resetVoiceSettings();
setDsLocal(createDefaultDataSourceSettings());
setAiContent('');
setAiResult('');
setDataSaved(false);
}, [resetDataSources, resetUISettings, resetVoiceSettings]);

useEffect(() => {
const token = createAppDataWriteToken();
getAppDataItem(STT_SETTINGS_KEY, token).then((raw) => {
if (raw) {
try {
setSttSettings({ ...DEFAULT_STT_SETTINGS, ...(JSON.parse(raw) as Partial<STTSettings>) });
} catch (parseErr) {
console.warn('[dnd-ref] Failed to parse STT settings:', parseErr);
}
}
});
refreshUploads();
return () => {
if (voiceSavedTimer.current) clearTimeout(voiceSavedTimer.current);
if (dataSavedTimer.current) clearTimeout(dataSavedTimer.current);
};
}, [refreshUploads]);
const filesCategory = useFilesSettingsCategory({
bumpUploads,
stopSession,
onDeleteAllDataReset: resetAfterDeleteAll,
});

useEffect(() => { setDsLocal(ds); }, [ds]);
useEffect(() => () => {
if (dataSavedTimer.current) clearTimeout(dataSavedTimer.current);
}, []);

const saveVoice = async () => {
const token = createAppDataWriteToken();
const saved = await setAppDataItem(STT_SETTINGS_KEY, JSON.stringify(sttSettings), { token });
if (!saved || !isAppDataWriteTokenCurrent(token)) return;
setVoiceSaved(true);
if (voiceSavedTimer.current) clearTimeout(voiceSavedTimer.current);
voiceSavedTimer.current = setTimeout(() => setVoiceSaved(false), 2000);
};
useEffect(() => { setDsLocal(ds); }, [ds]);

const saveData = async () => {
await updateDs(dsLocal);
Expand All @@ -113,67 +74,6 @@ export default function SettingsScreen() {
dataSavedTimer.current = setTimeout(() => setDataSaved(false), 2000);
};

const pickFilesWeb = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '.md,.txt,.json';
input.onchange = async () => {
const files = Array.from(input.files ?? []);
input.onchange = null;
await Promise.all(files.map((f) => f.text().then((text) => addUpload(f.name, text))));
await refreshUploads();
};
input.click();
};

const handlePasteAdd = async () => {
const name = pasteFileName.trim() || 'Pasted Content.md';
if (!pasteContent.trim()) return;
await addUpload(name, pasteContent);
setPasteFileName('');
setPasteContent('');
await refreshUploads();
};

const handleDeleteUpload = async (id: string) => {
setRemovingUploadId(id);
try {
await removeUpload(id);
await refreshUploads();
} finally {
setRemovingUploadId((current) => (current === id ? null : current));
}
};

const handleDeleteAllData = async () => {
const confirmed = await confirmDeleteAllData();
if (!confirmed) return;

setDeleteAllPending(true);
setDeleteAllStatus('');
try {
stopSession();
await resetStoredAppData({ beforeClear: waitForUploadMutations });
resetDataSources();
resetUISettings();
setSttSettings(DEFAULT_STT_SETTINGS);
setDsLocal(DEFAULT_DATA_SOURCES_SETTINGS);
setUploads([]);
setPasteFileName('');
setPasteContent('');
setAiContent('');
setAiResult('');
setVoiceSaved(false);
setDataSaved(false);
setDeleteAllStatus('All local app data was deleted.');
} catch (e: unknown) {
setDeleteAllStatus(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setDeleteAllPending(false);
}
};

const handleAIParse = async () => {
if (!aiContent.trim() || !dsLocal.aiApiKey) return;
const token = createAppDataWriteToken();
Expand All @@ -183,9 +83,7 @@ export default function SettingsScreen() {
const entities = await parseWithAI(aiContent, dsLocal.aiApiKey);
if (!isAppDataWriteTokenCurrent(token)) return;
const name = `AI Parsed ${new Date().toLocaleDateString()}.json`;
await addUpload(name, JSON.stringify(entities));
if (!isAppDataWriteTokenCurrent(token)) return;
await refreshUploads();
await filesCategory.saveUpload(name, JSON.stringify(entities));
if (!isAppDataWriteTokenCurrent(token)) return;
setAiResult(`Found ${entities.length} entities. Saved as "${name}".`);
setAiContent('');
Expand All @@ -196,8 +94,6 @@ export default function SettingsScreen() {
}
};

const isWebSpeech = Platform.OS === 'web';

const renderContent = () => {
switch (category) {
case 'display':
Expand All @@ -209,18 +105,18 @@ export default function SettingsScreen() {
case 'files':
return (
<FilesSection
uploads={uploads}
removingUploadId={removingUploadId}
pasteFileName={pasteFileName}
setPasteFileName={setPasteFileName}
pasteContent={pasteContent}
setPasteContent={setPasteContent}
pickFilesWeb={pickFilesWeb}
handlePasteAdd={handlePasteAdd}
handleDeleteUpload={handleDeleteUpload}
handleDeleteAllData={handleDeleteAllData}
deleteAllPending={deleteAllPending}
deleteAllStatus={deleteAllStatus}
uploads={filesCategory.uploads}
removingUploadId={filesCategory.removingUploadId}
pasteFileName={filesCategory.pasteFileName}
setPasteFileName={filesCategory.setPasteFileName}
pasteContent={filesCategory.pasteContent}
setPasteContent={filesCategory.setPasteContent}
pickFilesWeb={filesCategory.pickFilesWeb}
handlePasteAdd={filesCategory.handlePasteAdd}
handleDeleteUpload={filesCategory.handleDeleteUpload}
handleDeleteAllData={filesCategory.handleDeleteAllData}
deleteAllPending={filesCategory.deleteAllPending}
deleteAllStatus={filesCategory.deleteAllStatus}
styles={styles}
/>
);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"proxy:dev": "wrangler dev --config workers/cors-proxy/wrangler.toml",
"proxy:deploy": "wrangler deploy --config workers/cors-proxy/wrangler.toml",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
Expand Down
73 changes: 18 additions & 55 deletions src/components/CardGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,40 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Animated, ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native';

import { EntityCard } from './EntityCard';
import { CardState, useSession } from '../context/session';
import { CARD_SIZE_CONFIGS, useColors, useUISettings } from '../context/ui-settings';
import { useSession } from '../context/session';
import { useColors, useUISettings } from '../context/ui-settings';
import { computeReferenceCardLayout, type ReferenceCardPosition } from '../reference-card-layout';
import { Colors, F } from '../theme';
import { EntityCard } from './EntityCard';

const GRID_PAD = 5;
const CARD_MARGIN = 5;
const MIN_CARD_WIDTH = 230;
const MAX_CARD_WIDTH = 380;

interface CardPos { x: number; y: number }
interface AnimPair { left: Animated.Value; top: Animated.Value }

function computePositions(
cards: CardState[],
heights: Record<string, number>,
columns: number,
cardWidth: number,
xOffset: number,
): { positions: Record<string, CardPos>; totalHeight: number } {
const colWidth = cardWidth + 2 * CARD_MARGIN;

const rowHeights: number[] = [];
for (let i = 0; i < cards.length; i++) {
const row = Math.floor(i / columns);
const h = heights[cards[i].instanceId] ?? 200;
rowHeights[row] = Math.max(rowHeights[row] ?? 0, h);
}

const rowY: number[] = [GRID_PAD];
for (let r = 0; r < rowHeights.length; r++) {
rowY[r + 1] = rowY[r] + rowHeights[r];
}

const positions: Record<string, CardPos> = {};
for (let i = 0; i < cards.length; i++) {
const col = i % columns;
const row = Math.floor(i / columns);
positions[cards[i].instanceId] = {
x: xOffset + GRID_PAD + col * colWidth,
y: rowY[row],
};
}

return { positions, totalHeight: rowY[rowHeights.length] + GRID_PAD };
}

const SPRING_CONFIG = { friction: 22, tension: 55, useNativeDriver: false } as const;

export function CardGrid() {
const C = useColors();
const { cards, status, pin, unpin, dismiss } = useSession();
const { cardSize } = useUISettings();
const { width, height: winHeight } = useWindowDimensions();
const config = CARD_SIZE_CONFIGS[cardSize];
const preferredColumns = width > winHeight ? config.landscapeCols : config.portraitCols;
const readableColumns = Math.max(1, Math.floor((width - 2 * GRID_PAD) / (MIN_CARD_WIDTH + 2 * CARD_MARGIN)));
const columns = Math.min(preferredColumns, readableColumns);
const gridWidth = Math.min(width, columns * (MAX_CARD_WIDTH + 2 * CARD_MARGIN) + 2 * GRID_PAD);
const xOffset = Math.max(0, (width - gridWidth) / 2);
const cardWidth = (gridWidth - 2 * GRID_PAD) / columns - 2 * CARD_MARGIN;
const styles = useMemo(() => createStyles(C), [C]);

const [cardHeights, setCardHeights] = useState<Record<string, number>>({});

const animRef = useRef<Record<string, AnimPair>>({});
const prevPos = useRef<Record<string, CardPos>>({});

const { positions: targets, totalHeight } = useMemo(
() => computePositions(cards, cardHeights, columns, cardWidth, xOffset),
[cards, cardHeights, columns, cardWidth, xOffset],
const prevPos = useRef<Record<string, ReferenceCardPosition>>({});

const {
cardWidth,
positions: targets,
totalHeight,
} = useMemo(
() => computeReferenceCardLayout({
cards,
measuredHeights: cardHeights,
viewport: { width, height: winHeight },
cardSize,
}),
[cards, cardHeights, width, winHeight, cardSize],
);

for (const [id, pos] of Object.entries(targets)) {
Expand Down
Loading