Add MVP features: edit mode, voice input, Gitea push, service autocomplete

- new.tsx: edit mode via editId param, per-field voice dictation (MIC/STOP),
  service autocomplete dropdown from DB history
- incident/[id].tsx: Push to Gitea action reads SecureStore config + md template
- index.tsx: homeView=capture redirects to /new on first mount
- _layout.tsx: onboarding gate wired (useSettings + router.replace)
- ci: GitHub Actions workflow builds debug APK via expo prebuild + Gradle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 12:03:44 +02:00
parent 8388f49fd4
commit 418eb1daa1
5 changed files with 294 additions and 19 deletions
+51
View File
@@ -0,0 +1,51 @@
name: Build Android APK
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-
- name: Generate Android native project
run: npx expo prebuild --platform android --clean
- name: Build debug APK
working-directory: android
run: |
chmod +x gradlew
./gradlew assembleDebug --no-daemon
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: SheetHappens-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk
retention-days: 30
+12 -1
View File
@@ -1,10 +1,21 @@
import { Stack } from "expo-router"; import { useEffect } from "react";
import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "../global.css"; import "../global.css";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useSettings } from "@/hooks/useSettings";
export default function RootLayout() { export default function RootLayout() {
const { settings, ready } = useSettings();
useEffect(() => {
if (!ready) return;
if (!settings.onboardingDone) {
router.replace("/onboarding");
}
}, [ready, settings.onboardingDone]);
return ( return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}> <GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
<StatusBar style="light" /> <StatusBar style="light" />
+53
View File
@@ -9,8 +9,10 @@ import {
} from "react-native"; } from "react-native";
import { useLocalSearchParams, router, useNavigation } from "expo-router"; import { useLocalSearchParams, router, useNavigation } from "expo-router";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident } from "@/lib/db"; import { getIncidentById, updateIncident } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown"; import { renderMarkdown } from "@/lib/markdown";
import { pushIncidentToGitea } from "@/lib/gitea";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
@@ -38,6 +40,7 @@ export default function IncidentDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const navigation = useNavigation(); const navigation = useNavigation();
const [incident, setIncident] = useState<Incident | null>(null); const [incident, setIncident] = useState<Incident | null>(null);
const [pushing, setPushing] = useState(false);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
@@ -56,6 +59,33 @@ export default function IncidentDetailScreen() {
setIncident({ ...incident, status: "resolved" }); setIncident({ ...incident, status: "resolved" });
} }
async function handleGiteaPush() {
if (!incident) return;
const [url, token, owner, repo, template] = await Promise.all([
SecureStore.getItemAsync("gitea_url"),
SecureStore.getItemAsync("gitea_token"),
SecureStore.getItemAsync("gitea_owner"),
SecureStore.getItemAsync("gitea_repo"),
SecureStore.getItemAsync("md_template"),
]);
if (!url || !token || !owner || !repo) {
Alert.alert("Gitea not configured", "Set up Gitea in Settings first.");
return;
}
setPushing(true);
const result = await pushIncidentToGitea(
incident,
{ url, token, owner, repo },
template ?? undefined
);
setPushing(false);
if (result.success) {
Alert.alert("Pushed", result.url ? `${result.url}` : "Push successful.");
} else {
Alert.alert("Push failed", result.error ?? "Unknown error");
}
}
async function handleExport() { async function handleExport() {
if (!incident) return; if (!incident) return;
const md = renderMarkdown(incident); const md = renderMarkdown(incident);
@@ -188,6 +218,29 @@ export default function IncidentDetailScreen() {
</Text> </Text>
</Pressable> </Pressable>
<Pressable
onPress={handleGiteaPush}
disabled={pushing}
style={{
backgroundColor: pushing ? Colors.surface2 : Colors.surface,
borderWidth: 1,
borderColor: pushing ? Colors.border : Colors.primary,
borderRadius: 10,
padding: 14,
alignItems: "center",
}}
>
<Text
style={{
color: pushing ? Colors.textDim : Colors.primary,
fontWeight: "600",
fontSize: 15,
}}
>
{pushing ? "Pushing…" : "Push to Gitea"}
</Text>
</Pressable>
<Pressable <Pressable
onPress={() => router.push({ pathname: "/new", params: { editId: incident.id } })} onPress={() => router.push({ pathname: "/new", params: { editId: incident.id } })}
style={{ style={{
+12 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
View, View,
Text, Text,
@@ -8,12 +8,23 @@ import {
} from "react-native"; } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { getIncidents } from "@/lib/db"; import { getIncidents } from "@/lib/db";
import { useSettings } from "@/hooks/useSettings";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
export default function HomeScreen() { export default function HomeScreen() {
const [incidents, setIncidents] = useState<Incident[]>([]); const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { settings, ready } = useSettings();
const didRedirect = useRef(false);
useEffect(() => {
if (!ready || didRedirect.current) return;
if (settings.homeView === "capture") {
didRedirect.current = true;
router.push("/new");
}
}, [ready, settings.homeView]);
useEffect(() => { useEffect(() => {
loadIncidents(); loadIncidents();
+165 -16
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { import {
View, View,
Text, Text,
@@ -9,9 +9,15 @@ import {
Platform, Platform,
Alert, Alert,
} from "react-native"; } from "react-native";
import { router } from "expo-router"; import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { createIncident } from "@/lib/db"; import {
createIncident,
updateIncident,
getIncidentById,
getDistinctServices,
} from "@/lib/db";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useVoice } from "@/hooks/useVoice";
import type { IncidentDraft } from "@/types/incident"; import type { IncidentDraft } from "@/types/incident";
const FIELD_STYLE = { const FIELD_STYLE = {
@@ -38,10 +44,14 @@ const LABEL_STYLE = {
fontWeight: "600" as const, fontWeight: "600" as const,
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
letterSpacing: 0.5, letterSpacing: 0.5,
marginBottom: 6,
} as const; } as const;
type VoiceField = "title" | "symptom" | "rootCause" | "fix";
export default function NewIncidentScreen() { export default function NewIncidentScreen() {
const { editId } = useLocalSearchParams<{ editId?: string }>();
const navigation = useNavigation();
const [form, setForm] = useState<IncidentDraft>({ const [form, setForm] = useState<IncidentDraft>({
title: "", title: "",
service: "", service: "",
@@ -53,6 +63,59 @@ export default function NewIncidentScreen() {
}); });
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [knownServices, setKnownServices] = useState<string[]>([]);
const [serviceFocused, setServiceFocused] = useState(false);
const { state: voiceState, transcript, start: startVoice, stop: stopVoice } = useVoice();
const voiceFieldRef = useRef<VoiceField | null>(null);
const [listeningField, setListeningField] = useState<VoiceField | null>(null);
useEffect(() => {
getDistinctServices().then(setKnownServices);
}, []);
useEffect(() => {
if (!editId) return;
navigation.setOptions({ title: "Edit Incident" });
getIncidentById(editId).then((inc) => {
if (!inc) return;
setForm({
title: inc.title,
service: inc.service,
symptom: inc.symptom,
rootCause: inc.rootCause,
fix: inc.fix,
status: inc.status,
tags: inc.tags,
});
});
}, [editId]);
// Live-fill the active voice field as transcript updates
useEffect(() => {
const field = voiceFieldRef.current;
if (transcript && field) {
setForm((f) => ({ ...f, [field]: transcript }));
}
}, [transcript]);
// Clear listening state when recognition ends
useEffect(() => {
if ((voiceState === "idle" || voiceState === "error") && voiceFieldRef.current !== null) {
voiceFieldRef.current = null;
setListeningField(null);
}
}, [voiceState]);
function handleMic(field: VoiceField) {
if (listeningField !== null) {
stopVoice();
return;
}
voiceFieldRef.current = field;
setListeningField(field);
startVoice("fr-FR");
}
function set(key: keyof IncidentDraft) { function set(key: keyof IncidentDraft) {
return (value: string) => setForm((f) => ({ ...f, [key]: value })); return (value: string) => setForm((f) => ({ ...f, [key]: value }));
@@ -77,15 +140,71 @@ export default function NewIncidentScreen() {
} }
setSaving(true); setSaving(true);
try { try {
if (editId) {
await updateIncident(editId, form);
} else {
await createIncident(form); await createIncident(form);
}
router.back(); router.back();
} catch (e) { } catch {
Alert.alert("Error", "Failed to save incident."); Alert.alert("Error", "Failed to save incident.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
const filteredServices =
serviceFocused && form.service.length > 0
? knownServices.filter(
(s) =>
s.toLowerCase().includes(form.service.toLowerCase()) &&
s !== form.service
)
: [];
function micButton(field: VoiceField) {
const active = listeningField === field;
return (
<Pressable
onPress={() => handleMic(field)}
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 4,
backgroundColor: active ? "#ef444420" : Colors.surface2,
borderWidth: 1,
borderColor: active ? Colors.danger : Colors.border,
}}
>
<Text
style={{
color: active ? Colors.danger : Colors.text2,
fontSize: 11,
fontWeight: "700",
}}
>
{active ? "STOP" : "MIC"}
</Text>
</Pressable>
);
}
function labelRow(label: string, field: VoiceField) {
return (
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
}}
>
<Text style={LABEL_STYLE}>{label}</Text>
{micButton(field)}
</View>
);
}
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
@@ -95,7 +214,7 @@ export default function NewIncidentScreen() {
contentContainerStyle={{ padding: 16, paddingBottom: 32 }} contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<Text style={LABEL_STYLE}>Title *</Text> {labelRow("Title *", "title")}
<TextInput <TextInput
style={FIELD_STYLE} style={FIELD_STYLE}
value={form.title} value={form.title}
@@ -105,18 +224,50 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<Text style={LABEL_STYLE}>Service</Text> <Text style={[LABEL_STYLE, { marginBottom: 6 }]}>Service</Text>
<TextInput <TextInput
style={FIELD_STYLE} style={FIELD_STYLE}
value={form.service} value={form.service}
onChangeText={set("service")} onChangeText={set("service")}
onFocus={() => setServiceFocused(true)}
onBlur={() => setTimeout(() => setServiceFocused(false), 150)}
placeholder="e.g. ghost-blog, k3s-cluster, haproxy" placeholder="e.g. ghost-blog, k3s-cluster, haproxy"
placeholderTextColor={Colors.textDim} placeholderTextColor={Colors.textDim}
autoCapitalize="none" autoCapitalize="none"
returnKeyType="next" returnKeyType="next"
/> />
{filteredServices.length > 0 && (
<View
style={{
backgroundColor: Colors.surface2,
borderRadius: 8,
borderWidth: 1,
borderColor: Colors.border,
marginTop: -12,
marginBottom: 16,
overflow: "hidden",
}}
>
{filteredServices.slice(0, 5).map((s, i) => (
<Pressable
key={s}
onPress={() => {
setForm((f) => ({ ...f, service: s }));
setServiceFocused(false);
}}
style={{
padding: 12,
borderTopWidth: i === 0 ? 0 : 1,
borderTopColor: Colors.border,
}}
>
<Text style={{ color: Colors.text1, fontSize: 14 }}>{s}</Text>
</Pressable>
))}
</View>
)}
<Text style={LABEL_STYLE}>Symptom</Text> {labelRow("Symptom", "symptom")}
<TextInput <TextInput
style={MONO_FIELD_STYLE} style={MONO_FIELD_STYLE}
value={form.symptom} value={form.symptom}
@@ -127,7 +278,7 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<Text style={LABEL_STYLE}>Root Cause</Text> {labelRow("Root Cause", "rootCause")}
<TextInput <TextInput
style={MONO_FIELD_STYLE} style={MONO_FIELD_STYLE}
value={form.rootCause} value={form.rootCause}
@@ -138,7 +289,7 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<Text style={LABEL_STYLE}>Fix Applied</Text> {labelRow("Fix Applied", "fix")}
<TextInput <TextInput
style={MONO_FIELD_STYLE} style={MONO_FIELD_STYLE}
value={form.fix} value={form.fix}
@@ -149,7 +300,7 @@ export default function NewIncidentScreen() {
returnKeyType="done" returnKeyType="done"
/> />
<Text style={LABEL_STYLE}>Tags</Text> <Text style={[LABEL_STYLE, { marginBottom: 6 }]}>Tags</Text>
<TextInput <TextInput
style={FIELD_STYLE} style={FIELD_STYLE}
value={tagInput} value={tagInput}
@@ -181,7 +332,7 @@ export default function NewIncidentScreen() {
}} }}
> >
<Text style={{ color: Colors.text2, fontSize: 13 }}>{tag}</Text> <Text style={{ color: Colors.text2, fontSize: 13 }}>{tag}</Text>
<Text style={{ color: Colors.textDim, fontSize: 12 }}></Text> <Text style={{ color: Colors.textDim, fontSize: 12 }}>x</Text>
</Pressable> </Pressable>
))} ))}
</View> </View>
@@ -198,10 +349,8 @@ export default function NewIncidentScreen() {
marginTop: 8, marginTop: 8,
}} }}
> >
<Text <Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}>
style={{ color: "#fff", fontSize: 16, fontWeight: "700" }} {saving ? "Saving…" : editId ? "Save Changes" : "Save Incident"}
>
{saving ? "Saving…" : "Save Incident"}
</Text> </Text>
</Pressable> </Pressable>
</ScrollView> </ScrollView>