Mobile UI: bottom tab navigation + auto-build APK on push
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:
@@ -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:
|
||||
|
||||
@@ -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,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 (
|
||||
<ThemedView style={styles.fill}>
|
||||
<Stack.Screen options={{ title: 'Ask' }} />
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
Ask a question and get an answer grounded in your lectures — every claim links back to the
|
||||
@@ -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<PickedAudio | null>(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 (
|
||||
<ThemedView style={styles.fill}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.flex}>
|
||||
<ThemedText type="subtitle">Wisp</ThemedText>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
Private transcription — runs on your device, nothing uploaded.
|
||||
<View style={styles.subHeader}>
|
||||
<ThemedText type="small" themeColor="textSecondary" style={styles.flex}>
|
||||
On your device — nothing uploaded.
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.headerLinks}>
|
||||
<Link href="/search" asChild>
|
||||
<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>
|
||||
<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 style={styles.captureRow}>
|
||||
@@ -120,7 +82,7 @@ export default function LibraryScreen() {
|
||||
styles.captureBtn,
|
||||
{ backgroundColor: '#e5484d', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
|
||||
]}>
|
||||
<ThemedText style={styles.newButtonText}>🎙 Record</ThemedText>
|
||||
<ThemedText style={styles.captureText}>🎙 Record</ThemedText>
|
||||
</Pressable>
|
||||
</Link>
|
||||
<Pressable
|
||||
@@ -130,7 +92,7 @@ export default function LibraryScreen() {
|
||||
styles.captureBtn,
|
||||
{ backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
|
||||
]}>
|
||||
<ThemedText style={styles.newButtonText}>+ Import file</ThemedText>
|
||||
<ThemedText style={styles.captureText}>+ Import</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -142,6 +104,7 @@ export default function LibraryScreen() {
|
||||
</ThemedView>
|
||||
)}
|
||||
|
||||
{courses.length > 0 && (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterBar}>
|
||||
<FilterChip label="All" active={courseFilter === 'all'} onPress={() => void setCourseFilter('all')} />
|
||||
<FilterChip label="Unsorted" active={courseFilter === null} onPress={() => void setCourseFilter(null)} />
|
||||
@@ -149,11 +112,12 @@ export default function LibraryScreen() {
|
||||
<FilterChip key={c.id} label={c.name} active={courseFilter === c.id} onPress={() => void setCourseFilter(c.id)} />
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={(t) => 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() {
|
||||
<ActivityIndicator style={styles.pad} />
|
||||
) : items.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
items.map((t) => (
|
||||
@@ -245,9 +209,7 @@ function TranscriptRow({
|
||||
<Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}>
|
||||
<ThemedView type="backgroundElement" style={styles.card}>
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText type="smallBold" numberOfLines={1} style={styles.flex}>
|
||||
{item.title}
|
||||
</ThemedText>
|
||||
<ThemedText type="smallBold" numberOfLines={1} style={styles.flex}>{item.title}</ThemedText>
|
||||
<Pressable onPress={onDelete} hitSlop={8}>
|
||||
<ThemedText type="small" themeColor="textSecondary">✕</ThemedText>
|
||||
</Pressable>
|
||||
@@ -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' },
|
||||
@@ -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 (
|
||||
<ThemedView style={styles.fill}>
|
||||
<Stack.Screen options={{ title: 'Search' }} />
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
Semantic search across your lectures — runs entirely on your device.
|
||||
@@ -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 (
|
||||
<ThemedView style={styles.fill}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Study',
|
||||
headerRight: () => (
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<View style={styles.topActions}>
|
||||
<Pressable onPress={() => void exportAnki()} hitSlop={8}>
|
||||
<ThemedText type="link" themeColor="textSecondary">Export Anki (.csv)</ThemedText>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterBar}>
|
||||
<FilterChip label="All" active={scope === 'all'} onPress={() => pickScope('all')} />
|
||||
<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 },
|
||||
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 },
|
||||
+1
-5
@@ -14,16 +14,12 @@ export default function RootLayout() {
|
||||
return (
|
||||
<ThemeProvider value={scheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<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="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="study" options={{ title: 'Study' }} />
|
||||
<Stack.Screen name="quiz" options={{ title: 'Quiz' }} />
|
||||
<Stack.Screen name="bibliography" options={{ title: 'Bibliography' }} />
|
||||
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
|
||||
Reference in New Issue
Block a user