Mobile UI: bottom tab navigation + auto-build APK on push
CI / test (push) Successful in 20s
CI / deploy-web (push) Successful in 27s
CI / build-apk (push) Successful in 42m14s

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: <Tabs> 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 <Stack.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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:40:32 +02:00
parent 7611ada001
commit 108ac59cb1
8 changed files with 101 additions and 99 deletions
+9 -9
View File
@@ -7,12 +7,12 @@
# `docker compose` simply builds and (re)creates the `wisp` container on the host. # `docker compose` simply builds and (re)creates the `wisp` container on the host.
# #
# Triggers: # Triggers:
# push to master -> test, then deploy-web # push to master -> test, then deploy-web, then build-apk
# pull_request -> test only # pull_request -> test only
# #
# Required repo secrets (Settings -> Actions -> Secrets) — used by the APK job # Required repo secrets (Settings -> Actions -> Secrets) — used by the build-apk
# once it is enabled (see TODO at the bottom): ANDROID_KEYSTORE_BASE64, # job: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS,
# ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD. # ANDROID_KEY_PASSWORD.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
name: CI name: CI
@@ -72,11 +72,11 @@ jobs:
# with docker create/cp and copy it into the running `wisp` web container so # with docker create/cp and copy it into the running `wisp` web container so
# it is downloadable at https://wisp.briggen.dev/wisp.apk. # it is downloadable at https://wisp.briggen.dev/wisp.apk.
build-apk: build-apk:
# ONLY on version tags (v*). The native build is heavy (NDK/whisper.cpp) and # Auto-build the APK on every push to master (arm64-only keeps it ~minutes).
# slow on the shared prod runner; building it on every push would peg the box # Runs after deploy-web so the web updates promptly, then the APK follows and
# (and degrade the live web apps). Cut a release with: git tag v1.2.3 && git push --tags # is published to /srv/wisp/wisp.apk (served at /wisp.apk via the compose mount).
needs: [test] needs: [test, deploy-web]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 120 timeout-minutes: 120
steps: steps:
+51
View File
@@ -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 <Text style={{ fontSize: 20, color }}>{glyph}</Text>;
}
export default function TabsLayout() {
const theme = useTheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: ACCENT,
tabBarInactiveTintColor: theme.textSecondary,
tabBarStyle: { backgroundColor: theme.background, borderTopColor: theme.backgroundElement },
headerStyle: { backgroundColor: theme.background },
headerTitleStyle: { color: theme.text },
headerTintColor: theme.text,
headerShadowVisible: false,
}}>
<Tabs.Screen
name="index"
options={{ title: 'Wisp', tabBarLabel: 'Library', tabBarIcon: ({ color }) => <TabIcon glyph="📚" color={color} /> }}
/>
<Tabs.Screen
name="search"
options={{ title: 'Search', tabBarIcon: ({ color }) => <TabIcon glyph="🔎" color={color} /> }}
/>
<Tabs.Screen
name="study"
options={{ title: 'Study', tabBarIcon: ({ color }) => <TabIcon glyph="🎴" color={color} /> }}
/>
<Tabs.Screen
name="ask"
options={{ title: 'Ask', tabBarIcon: ({ color }) => <TabIcon glyph="✨" color={color} /> }}
/>
<Tabs.Screen
name="settings"
options={{ title: 'Settings', tabBarIcon: ({ color }) => <TabIcon glyph="⚙️" color={color} /> }}
/>
</Tabs>
);
}
+1 -2
View File
@@ -1,4 +1,4 @@
import { Stack, useFocusEffect, useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
@@ -81,7 +81,6 @@ export default function AskScreen() {
return ( return (
<ThemedView style={styles.fill}> <ThemedView style={styles.fill}>
<Stack.Screen options={{ title: 'Ask' }} />
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<ThemedText type="small" themeColor="textSecondary"> <ThemedText type="small" themeColor="textSecondary">
Ask a question and get an answer grounded in your lectures every claim links back to the Ask a question and get an answer grounded in your lectures every claim links back to the
+31 -70
View File
@@ -14,13 +14,12 @@ import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view'; import { ThemedView } from '@/components/themed-view';
import { MaxContentWidth, Spacing } from '@/constants/theme'; import { MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-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 { formatClock } from '@/lib/format';
import { MODELS } from '@/lib/models/catalog';
import { pickAudio, type PickedAudio } from '@/lib/pickAudio'; import { pickAudio, type PickedAudio } from '@/lib/pickAudio';
import { useCourses } from '@/stores/coursesStore'; import { useCourses } from '@/stores/coursesStore';
import { useTranscribe } from '@/stores/transcribeStore'; import { useTranscribe } from '@/stores/transcribeStore';
import { useTranscripts, type CourseFilter } from '@/stores/transcriptsStore'; import { useTranscripts } from '@/stores/transcriptsStore';
export default function LibraryScreen() { export default function LibraryScreen() {
const theme = useTheme(); const theme = useTheme();
@@ -31,21 +30,11 @@ export default function LibraryScreen() {
const refreshCourses = useCourses((s) => s.refresh); const refreshCourses = useCourses((s) => s.refresh);
const job = useTranscribe(); const job = useTranscribe();
const [picked, setPicked] = useState<PickedAudio | null>(null); const [picked, setPicked] = useState<PickedAudio | null>(null);
const [dueCount, setDueCount] = useState(0);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
void refresh(); void refresh();
void refreshCourses(); void refreshCourses();
let alive = true;
void getRepo()
.flashcardCounts()
.then((c) => {
if (alive) setDueCount(c.due);
});
return () => {
alive = false;
};
}, [refresh, refreshCourses]), }, [refresh, refreshCourses]),
); );
@@ -54,7 +43,7 @@ export default function LibraryScreen() {
const onNew = useCallback(async () => { const onNew = useCallback(async () => {
if (busy) return; if (busy) return;
const p = await pickAudio(); const p = await pickAudio();
if (p) setPicked(p); // opens the pre-capture sheet if (p) setPicked(p);
}, [busy]); }, [busy]);
const onStart = useCallback( const onStart = useCallback(
@@ -74,42 +63,15 @@ export default function LibraryScreen() {
return ( return (
<ThemedView style={styles.fill}> <ThemedView style={styles.fill}>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
<View style={styles.headerRow}> <View style={styles.subHeader}>
<View style={styles.flex}> <ThemedText type="small" themeColor="textSecondary" style={styles.flex}>
<ThemedText type="subtitle">Wisp</ThemedText> On your device nothing uploaded.
<ThemedText type="small" themeColor="textSecondary"> </ThemedText>
Private transcription runs on your device, nothing uploaded. <Link href="/courses" asChild>
</ThemedText> <Pressable hitSlop={8}>
</View> <ThemedText type="link" themeColor="textSecondary">Courses </ThemedText>
<View style={styles.headerLinks}> </Pressable>
<Link href="/search" asChild> </Link>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">Search</ThemedText>
</Pressable>
</Link>
<Link href="/ask" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">Ask</ThemedText>
</Pressable>
</Link>
<Link href="/courses" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">Courses</ThemedText>
</Pressable>
</Link>
<Link href="/study" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">
{dueCount > 0 ? `Study (${dueCount})` : 'Study'}
</ThemedText>
</Pressable>
</Link>
<Link href="/settings" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary"> Settings</ThemedText>
</Pressable>
</Link>
</View>
</View> </View>
<View style={styles.captureRow}> <View style={styles.captureRow}>
@@ -120,7 +82,7 @@ export default function LibraryScreen() {
styles.captureBtn, styles.captureBtn,
{ backgroundColor: '#e5484d', opacity: busy ? 0.5 : pressed ? 0.85 : 1 }, { backgroundColor: '#e5484d', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
]}> ]}>
<ThemedText style={styles.newButtonText}>🎙 Record</ThemedText> <ThemedText style={styles.captureText}>🎙 Record</ThemedText>
</Pressable> </Pressable>
</Link> </Link>
<Pressable <Pressable
@@ -130,7 +92,7 @@ export default function LibraryScreen() {
styles.captureBtn, styles.captureBtn,
{ backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 }, { backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
]}> ]}>
<ThemedText style={styles.newButtonText}> Import file</ThemedText> <ThemedText style={styles.captureText}> Import</ThemedText>
</Pressable> </Pressable>
</View> </View>
@@ -142,18 +104,20 @@ export default function LibraryScreen() {
</ThemedView> </ThemedView>
)} )}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterBar}> {courses.length > 0 && (
<FilterChip label="All" active={courseFilter === 'all'} onPress={() => void setCourseFilter('all')} /> <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterBar}>
<FilterChip label="Unsorted" active={courseFilter === null} onPress={() => void setCourseFilter(null)} /> <FilterChip label="All" active={courseFilter === 'all'} onPress={() => void setCourseFilter('all')} />
{courses.map((c) => ( <FilterChip label="Unsorted" active={courseFilter === null} onPress={() => void setCourseFilter(null)} />
<FilterChip key={c.id} label={c.name} active={courseFilter === c.id} onPress={() => void setCourseFilter(c.id)} /> {courses.map((c) => (
))} <FilterChip key={c.id} label={c.name} active={courseFilter === c.id} onPress={() => void setCourseFilter(c.id)} />
</ScrollView> ))}
</ScrollView>
)}
<TextInput <TextInput
value={query} value={query}
onChangeText={(t) => void setQuery(t)} onChangeText={(t) => void setQuery(t)}
placeholder="Search transcripts…" placeholder="Filter by title…"
placeholderTextColor={theme.textSecondary} placeholderTextColor={theme.textSecondary}
style={[styles.search, { color: theme.text, backgroundColor: theme.backgroundElement }]} style={[styles.search, { color: theme.text, backgroundColor: theme.backgroundElement }]}
/> />
@@ -162,7 +126,7 @@ export default function LibraryScreen() {
<ActivityIndicator style={styles.pad} /> <ActivityIndicator style={styles.pad} />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}> <ThemedText type="small" themeColor="textSecondary" style={styles.pad}>
{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.'}
</ThemedText> </ThemedText>
) : ( ) : (
items.map((t) => ( items.map((t) => (
@@ -245,9 +209,7 @@ function TranscriptRow({
<Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}> <Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}>
<ThemedView type="backgroundElement" style={styles.card}> <ThemedView type="backgroundElement" style={styles.card}>
<View style={styles.rowBetween}> <View style={styles.rowBetween}>
<ThemedText type="smallBold" numberOfLines={1} style={styles.flex}> <ThemedText type="smallBold" numberOfLines={1} style={styles.flex}>{item.title}</ThemedText>
{item.title}
</ThemedText>
<Pressable onPress={onDelete} hitSlop={8}> <Pressable onPress={onDelete} hitSlop={8}>
<ThemedText type="small" themeColor="textSecondary"></ThemedText> <ThemedText type="small" themeColor="textSecondary"></ThemedText>
</Pressable> </Pressable>
@@ -269,20 +231,19 @@ const styles = StyleSheet.create({
maxWidth: MaxContentWidth, maxWidth: MaxContentWidth,
width: '100%', width: '100%',
alignSelf: 'center', alignSelf: 'center',
paddingBottom: Spacing.six,
}, },
flex: { flex: 1 }, flex: { flex: 1 },
headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two }, subHeader: { flexDirection: 'row', alignItems: 'center', 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 },
captureRow: { flexDirection: 'row', gap: Spacing.two }, captureRow: { flexDirection: 'row', gap: Spacing.two },
captureBtn: { flex: 1, paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' }, 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 }, card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two }, rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three }, 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' }, 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' }, track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' },
bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' }, bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' },
pad: { paddingVertical: Spacing.four, textAlign: 'center' }, pad: { paddingVertical: Spacing.four, textAlign: 'center' },
@@ -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 { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native';
@@ -104,7 +104,6 @@ export default function SearchScreen() {
return ( return (
<ThemedView style={styles.fill}> <ThemedView style={styles.fill}>
<Stack.Screen options={{ title: 'Search' }} />
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<ThemedText type="small" themeColor="textSecondary"> <ThemedText type="small" themeColor="textSecondary">
Semantic search across your lectures runs entirely on your device. Semantic search across your lectures runs entirely on your device.
+7 -11
View File
@@ -1,4 +1,4 @@
import { Stack, useFocusEffect, useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native';
@@ -80,17 +80,12 @@ export default function StudyScreen() {
return ( return (
<ThemedView style={styles.fill}> <ThemedView style={styles.fill}>
<Stack.Screen
options={{
title: 'Study',
headerRight: () => (
<Pressable onPress={() => void exportAnki()} hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">Export Anki (.csv)</ThemedText>
</Pressable>
),
}}
/>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
<View style={styles.topActions}>
<Pressable onPress={() => void exportAnki()} hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">Export Anki (.csv)</ThemedText>
</Pressable>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterBar}> <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterBar}>
<FilterChip label="All" active={scope === 'all'} onPress={() => pickScope('all')} /> <FilterChip label="All" active={scope === 'all'} onPress={() => pickScope('all')} />
<FilterChip label="Unsorted" active={scope === null} onPress={() => pickScope(null)} /> <FilterChip label="Unsorted" active={scope === null} onPress={() => pickScope(null)} />
@@ -192,6 +187,7 @@ const styles = StyleSheet.create({
}, },
filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three }, 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.one, borderRadius: 999 },
topActions: { flexDirection: 'row', justifyContent: 'flex-end' },
chipActive: { color: '#fff', fontWeight: '700' }, chipActive: { color: '#fff', fontWeight: '700' },
card: { padding: Spacing.four, borderRadius: Spacing.three, gap: Spacing.three }, card: { padding: Spacing.four, borderRadius: Spacing.three, gap: Spacing.three },
front: { fontSize: 24, lineHeight: 32 }, front: { fontSize: 24, lineHeight: 32 },
+1 -5
View File
@@ -14,16 +14,12 @@ export default function RootLayout() {
return ( return (
<ThemeProvider value={scheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={scheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack> <Stack>
<Stack.Screen name="index" options={{ title: 'Wisp' }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="record" options={{ title: 'Record' }} /> <Stack.Screen name="record" options={{ title: 'Record' }} />
<Stack.Screen name="transcript/[id]" options={{ title: 'Transcript' }} /> <Stack.Screen name="transcript/[id]" options={{ title: 'Transcript' }} />
<Stack.Screen name="search" options={{ title: 'Search' }} />
<Stack.Screen name="ask" options={{ title: 'Ask' }} />
<Stack.Screen name="courses" options={{ title: 'Courses' }} /> <Stack.Screen name="courses" options={{ title: 'Courses' }} />
<Stack.Screen name="study" options={{ title: 'Study' }} />
<Stack.Screen name="quiz" options={{ title: 'Quiz' }} /> <Stack.Screen name="quiz" options={{ title: 'Quiz' }} />
<Stack.Screen name="bibliography" options={{ title: 'Bibliography' }} /> <Stack.Screen name="bibliography" options={{ title: 'Bibliography' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
</ThemeProvider> </ThemeProvider>