From 108ac59cb16c7a7bf880d0ce61484cb4f59bad40 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Sun, 14 Jun 2026 20:40:32 +0200 Subject: [PATCH] Mobile UI: bottom tab navigation + auto-build APK on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Library crammed five text nav-links into a non-wrapping row with no bottom navigation — unusable on phones. Restructure the primary screens into a bottom tab bar: - Add src/app/(tabs)/_layout.tsx: with 5 tabs (Library, Search, Study, Ask, Settings) with emoji icons and the accent active tint. - Move index/search/study/ask/settings into the (tabs) route group; the parens keep URLs unchanged (/, /search, /study, /ask, /settings). - Root _layout becomes a Stack hosting (tabs) (headerless) plus the secondary pushed screens (record, transcript/[id], courses, quiz, bibliography), each with a back-button header. - Drop the per-screen headers from the moved tabs (titles now come from Tabs); relocate Study's "Export Anki" action into the body. - Library: remove the cramped header link-row and duplicate title; add a "nothing uploaded" subheader and a Courses link. Verified at 375px: tab bar pinned to the bottom, 5 even tabs, navigation between tabs works. tsc clean, web export OK, 279 tests pass. CI: build-apk now runs automatically on every push to master (after deploy-web), so the APK at /wisp.apk tracks master instead of being frozen at a stale tag. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/ci.yml | 18 +++--- src/app/(tabs)/_layout.tsx | 51 +++++++++++++++ src/app/{ => (tabs)}/ask.tsx | 3 +- src/app/{ => (tabs)}/index.tsx | 101 +++++++++--------------------- src/app/{ => (tabs)}/search.tsx | 3 +- src/app/{ => (tabs)}/settings.tsx | 0 src/app/{ => (tabs)}/study.tsx | 18 +++--- src/app/_layout.tsx | 6 +- 8 files changed, 101 insertions(+), 99 deletions(-) create mode 100644 src/app/(tabs)/_layout.tsx rename src/app/{ => (tabs)}/ask.tsx (98%) rename src/app/{ => (tabs)}/index.tsx (69%) rename src/app/{ => (tabs)}/search.tsx (98%) rename src/app/{ => (tabs)}/settings.tsx (100%) rename src/app/{ => (tabs)}/study.tsx (95%) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ddad224..9aa87a3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -7,12 +7,12 @@ # `docker compose` simply builds and (re)creates the `wisp` container on the host. # # Triggers: -# push to master -> test, then deploy-web +# push to master -> test, then deploy-web, then build-apk # pull_request -> test only # -# Required repo secrets (Settings -> Actions -> Secrets) — used by the APK job -# once it is enabled (see TODO at the bottom): ANDROID_KEYSTORE_BASE64, -# ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD. +# Required repo secrets (Settings -> Actions -> Secrets) — used by the build-apk +# job: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, +# ANDROID_KEY_PASSWORD. # --------------------------------------------------------------------------- name: CI @@ -72,11 +72,11 @@ jobs: # with docker create/cp and copy it into the running `wisp` web container so # it is downloadable at https://wisp.briggen.dev/wisp.apk. build-apk: - # ONLY on version tags (v*). The native build is heavy (NDK/whisper.cpp) and - # slow on the shared prod runner; building it on every push would peg the box - # (and degrade the live web apps). Cut a release with: git tag v1.2.3 && git push --tags - needs: [test] - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + # Auto-build the APK on every push to master (arm64-only keeps it ~minutes). + # Runs after deploy-web so the web updates promptly, then the APK follows and + # is published to /srv/wisp/wisp.apk (served at /wisp.apk via the compose mount). + needs: [test, deploy-web] + if: github.event_name == 'push' && github.ref == 'refs/heads/master' runs-on: ubuntu-latest timeout-minutes: 120 steps: diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..baac9c8 --- /dev/null +++ b/src/app/(tabs)/_layout.tsx @@ -0,0 +1,51 @@ +// Bottom tab navigation for the primary destinations (mobile-first). Secondary +// screens (Record, Transcript, Courses, Quiz, Bibliography) are pushed on the +// root Stack from within these tabs. Route-group parens keep URLs unchanged +// (/, /search, /study, /ask, /settings). +import { Tabs } from 'expo-router'; +import { Text, type ColorValue } from 'react-native'; + +import { useTheme } from '@/hooks/use-theme'; + +const ACCENT = '#3c87f7'; + +function TabIcon({ glyph, color }: { glyph: string; color: ColorValue }) { + return {glyph}; +} + +export default function TabsLayout() { + const theme = useTheme(); + return ( + + }} + /> + }} + /> + }} + /> + }} + /> + }} + /> + + ); +} diff --git a/src/app/ask.tsx b/src/app/(tabs)/ask.tsx similarity index 98% rename from src/app/ask.tsx rename to src/app/(tabs)/ask.tsx index cf18e5c..f0a67b5 100644 --- a/src/app/ask.tsx +++ b/src/app/(tabs)/ask.tsx @@ -1,4 +1,4 @@ -import { Stack, useFocusEffect, useRouter } from 'expo-router'; +import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; import { ActivityIndicator, @@ -81,7 +81,6 @@ export default function AskScreen() { return ( - Ask a question and get an answer grounded in your lectures — every claim links back to the diff --git a/src/app/index.tsx b/src/app/(tabs)/index.tsx similarity index 69% rename from src/app/index.tsx rename to src/app/(tabs)/index.tsx index 573d63c..04e39ef 100644 --- a/src/app/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -14,13 +14,12 @@ import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { MaxContentWidth, Spacing } from '@/constants/theme'; import { useTheme } from '@/hooks/use-theme'; -import { getRepo, type TranscriptMeta } from '@/lib/db'; +import type { TranscriptMeta } from '@/lib/db'; import { formatClock } from '@/lib/format'; -import { MODELS } from '@/lib/models/catalog'; import { pickAudio, type PickedAudio } from '@/lib/pickAudio'; import { useCourses } from '@/stores/coursesStore'; import { useTranscribe } from '@/stores/transcribeStore'; -import { useTranscripts, type CourseFilter } from '@/stores/transcriptsStore'; +import { useTranscripts } from '@/stores/transcriptsStore'; export default function LibraryScreen() { const theme = useTheme(); @@ -31,21 +30,11 @@ export default function LibraryScreen() { const refreshCourses = useCourses((s) => s.refresh); const job = useTranscribe(); const [picked, setPicked] = useState(null); - const [dueCount, setDueCount] = useState(0); useFocusEffect( useCallback(() => { void refresh(); void refreshCourses(); - let alive = true; - void getRepo() - .flashcardCounts() - .then((c) => { - if (alive) setDueCount(c.due); - }); - return () => { - alive = false; - }; }, [refresh, refreshCourses]), ); @@ -54,7 +43,7 @@ export default function LibraryScreen() { const onNew = useCallback(async () => { if (busy) return; const p = await pickAudio(); - if (p) setPicked(p); // opens the pre-capture sheet + if (p) setPicked(p); }, [busy]); const onStart = useCallback( @@ -74,42 +63,15 @@ export default function LibraryScreen() { return ( - - - Wisp - - Private transcription — runs on your device, nothing uploaded. - - - - - - Search - - - - - Ask - - - - - Courses - - - - - - {dueCount > 0 ? `Study (${dueCount})` : 'Study'} - - - - - - ⚙ Settings - - - + + + On your device — nothing uploaded. + + + + Courses › + + @@ -120,7 +82,7 @@ export default function LibraryScreen() { styles.captureBtn, { backgroundColor: '#e5484d', opacity: busy ? 0.5 : pressed ? 0.85 : 1 }, ]}> - 🎙 Record + 🎙 Record - + Import file + + Import @@ -142,18 +104,20 @@ export default function LibraryScreen() { )} - - void setCourseFilter('all')} /> - void setCourseFilter(null)} /> - {courses.map((c) => ( - void setCourseFilter(c.id)} /> - ))} - + {courses.length > 0 && ( + + void setCourseFilter('all')} /> + void setCourseFilter(null)} /> + {courses.map((c) => ( + void setCourseFilter(c.id)} /> + ))} + + )} void setQuery(t)} - placeholder="Search transcripts…" + placeholder="Filter by title…" placeholderTextColor={theme.textSecondary} style={[styles.search, { color: theme.text, backgroundColor: theme.backgroundElement }]} /> @@ -162,7 +126,7 @@ export default function LibraryScreen() { ) : items.length === 0 ? ( - {query ? 'No matches.' : 'No transcripts here yet. Pick an audio or video file to begin.'} + {query ? 'No matches.' : 'No lectures yet. Record one or import an audio/video file to begin.'} ) : ( items.map((t) => ( @@ -245,9 +209,7 @@ function TranscriptRow({ [pressed && styles.pressed]}> - - {item.title} - + {item.title} @@ -269,20 +231,19 @@ const styles = StyleSheet.create({ maxWidth: MaxContentWidth, width: '100%', alignSelf: 'center', + paddingBottom: Spacing.six, }, flex: { flex: 1 }, - headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two }, - headerLinks: { flexDirection: 'row', gap: Spacing.three, alignItems: 'center' }, - newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' }, - newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 }, + subHeader: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two }, captureRow: { flexDirection: 'row', gap: Spacing.two }, captureBtn: { flex: 1, paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' }, + captureText: { color: '#fff', fontWeight: '700', fontSize: 16 }, card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two }, rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two }, filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three }, - filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 }, + filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: 999 }, chipActive: { color: '#fff', fontWeight: '700' }, - search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 }, + search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.three, fontSize: 15 }, track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' }, bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' }, pad: { paddingVertical: Spacing.four, textAlign: 'center' }, diff --git a/src/app/search.tsx b/src/app/(tabs)/search.tsx similarity index 98% rename from src/app/search.tsx rename to src/app/(tabs)/search.tsx index 808d139..ce8dd99 100644 --- a/src/app/search.tsx +++ b/src/app/(tabs)/search.tsx @@ -1,4 +1,4 @@ -import { Stack, useFocusEffect, useRouter } from 'expo-router'; +import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; @@ -104,7 +104,6 @@ export default function SearchScreen() { return ( - Semantic search across your lectures — runs entirely on your device. diff --git a/src/app/settings.tsx b/src/app/(tabs)/settings.tsx similarity index 100% rename from src/app/settings.tsx rename to src/app/(tabs)/settings.tsx diff --git a/src/app/study.tsx b/src/app/(tabs)/study.tsx similarity index 95% rename from src/app/study.tsx rename to src/app/(tabs)/study.tsx index 14f9fc0..fbabd75 100644 --- a/src/app/study.tsx +++ b/src/app/(tabs)/study.tsx @@ -1,4 +1,4 @@ -import { Stack, useFocusEffect, useRouter } from 'expo-router'; +import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; @@ -80,17 +80,12 @@ export default function StudyScreen() { return ( - ( - void exportAnki()} hitSlop={8}> - Export Anki (.csv) - - ), - }} - /> + + void exportAnki()} hitSlop={8}> + Export Anki (.csv) + + pickScope('all')} /> pickScope(null)} /> @@ -192,6 +187,7 @@ const styles = StyleSheet.create({ }, filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three }, filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 }, + topActions: { flexDirection: 'row', justifyContent: 'flex-end' }, chipActive: { color: '#fff', fontWeight: '700' }, card: { padding: Spacing.four, borderRadius: Spacing.three, gap: Spacing.three }, front: { fontSize: 24, lineHeight: 32 }, diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index ba03e6c..fb20bb1 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -14,16 +14,12 @@ export default function RootLayout() { return ( - + - - - -