From 418eb1daa163f8bffc0083d3296845268a744510 Mon Sep 17 00:00:00 2001 From: billisdead Date: Fri, 19 Jun 2026 12:03:44 +0200 Subject: [PATCH] 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 --- .github/workflows/build-android.yml | 51 ++++++++ app/_layout.tsx | 13 +- app/incident/[id].tsx | 53 ++++++++ app/index.tsx | 13 +- app/new.tsx | 183 +++++++++++++++++++++++++--- 5 files changed, 294 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/build-android.yml diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml new file mode 100644 index 0000000..583135a --- /dev/null +++ b/.github/workflows/build-android.yml @@ -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 diff --git a/app/_layout.tsx b/app/_layout.tsx index 8df6518..2e56cf7 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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 { GestureHandlerRootView } from "react-native-gesture-handler"; import "../global.css"; import { Colors } from "@/constants/theme"; +import { useSettings } from "@/hooks/useSettings"; export default function RootLayout() { + const { settings, ready } = useSettings(); + + useEffect(() => { + if (!ready) return; + if (!settings.onboardingDone) { + router.replace("/onboarding"); + } + }, [ready, settings.onboardingDone]); + return ( diff --git a/app/incident/[id].tsx b/app/incident/[id].tsx index 1a0b8e7..185bc06 100644 --- a/app/incident/[id].tsx +++ b/app/incident/[id].tsx @@ -9,8 +9,10 @@ import { } from "react-native"; import { useLocalSearchParams, router, useNavigation } from "expo-router"; import * as Clipboard from "expo-clipboard"; +import * as SecureStore from "expo-secure-store"; import { getIncidentById, updateIncident } from "@/lib/db"; import { renderMarkdown } from "@/lib/markdown"; +import { pushIncidentToGitea } from "@/lib/gitea"; import type { Incident } from "@/types/incident"; import { Colors } from "@/constants/theme"; @@ -38,6 +40,7 @@ export default function IncidentDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const navigation = useNavigation(); const [incident, setIncident] = useState(null); + const [pushing, setPushing] = useState(false); useEffect(() => { if (id) { @@ -56,6 +59,33 @@ export default function IncidentDetailScreen() { 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() { if (!incident) return; const md = renderMarkdown(incident); @@ -188,6 +218,29 @@ export default function IncidentDetailScreen() { + + + {pushing ? "Pushing…" : "Push to Gitea"} + + + router.push({ pathname: "/new", params: { editId: incident.id } })} style={{ diff --git a/app/index.tsx b/app/index.tsx index 1b3f0ad..983046b 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { View, Text, @@ -8,12 +8,23 @@ import { } from "react-native"; import { router } from "expo-router"; import { getIncidents } from "@/lib/db"; +import { useSettings } from "@/hooks/useSettings"; import type { Incident } from "@/types/incident"; import { Colors } from "@/constants/theme"; export default function HomeScreen() { const [incidents, setIncidents] = useState([]); 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(() => { loadIncidents(); diff --git a/app/new.tsx b/app/new.tsx index 2455c1b..975ba4a 100644 --- a/app/new.tsx +++ b/app/new.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { View, Text, @@ -9,9 +9,15 @@ import { Platform, Alert, } from "react-native"; -import { router } from "expo-router"; -import { createIncident } from "@/lib/db"; +import { router, useLocalSearchParams, useNavigation } from "expo-router"; +import { + createIncident, + updateIncident, + getIncidentById, + getDistinctServices, +} from "@/lib/db"; import { Colors } from "@/constants/theme"; +import { useVoice } from "@/hooks/useVoice"; import type { IncidentDraft } from "@/types/incident"; const FIELD_STYLE = { @@ -38,10 +44,14 @@ const LABEL_STYLE = { fontWeight: "600" as const, textTransform: "uppercase" as const, letterSpacing: 0.5, - marginBottom: 6, } as const; +type VoiceField = "title" | "symptom" | "rootCause" | "fix"; + export default function NewIncidentScreen() { + const { editId } = useLocalSearchParams<{ editId?: string }>(); + const navigation = useNavigation(); + const [form, setForm] = useState({ title: "", service: "", @@ -53,6 +63,59 @@ export default function NewIncidentScreen() { }); const [tagInput, setTagInput] = useState(""); const [saving, setSaving] = useState(false); + const [knownServices, setKnownServices] = useState([]); + const [serviceFocused, setServiceFocused] = useState(false); + + const { state: voiceState, transcript, start: startVoice, stop: stopVoice } = useVoice(); + const voiceFieldRef = useRef(null); + const [listeningField, setListeningField] = useState(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) { return (value: string) => setForm((f) => ({ ...f, [key]: value })); @@ -77,15 +140,71 @@ export default function NewIncidentScreen() { } setSaving(true); try { - await createIncident(form); + if (editId) { + await updateIncident(editId, form); + } else { + await createIncident(form); + } router.back(); - } catch (e) { + } catch { Alert.alert("Error", "Failed to save incident."); } finally { 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 ( + handleMic(field)} + style={{ + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 4, + backgroundColor: active ? "#ef444420" : Colors.surface2, + borderWidth: 1, + borderColor: active ? Colors.danger : Colors.border, + }} + > + + {active ? "STOP" : "MIC"} + + + ); + } + + function labelRow(label: string, field: VoiceField) { + return ( + + {label} + {micButton(field)} + + ); + } + return ( - Title * + {labelRow("Title *", "title")} - Service + Service setServiceFocused(true)} + onBlur={() => setTimeout(() => setServiceFocused(false), 150)} placeholder="e.g. ghost-blog, k3s-cluster, haproxy" placeholderTextColor={Colors.textDim} autoCapitalize="none" returnKeyType="next" /> + {filteredServices.length > 0 && ( + + {filteredServices.slice(0, 5).map((s, i) => ( + { + setForm((f) => ({ ...f, service: s })); + setServiceFocused(false); + }} + style={{ + padding: 12, + borderTopWidth: i === 0 ? 0 : 1, + borderTopColor: Colors.border, + }} + > + {s} + + ))} + + )} - Symptom + {labelRow("Symptom", "symptom")} - Root Cause + {labelRow("Root Cause", "rootCause")} - Fix Applied + {labelRow("Fix Applied", "fix")} - Tags + Tags {tag} - + x ))} @@ -198,10 +349,8 @@ export default function NewIncidentScreen() { marginTop: 8, }} > - - {saving ? "Saving…" : "Save Incident"} + + {saving ? "Saving…" : editId ? "Save Changes" : "Save Incident"}