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:
+166
-17
@@ -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<IncidentDraft>({
|
||||
title: "",
|
||||
service: "",
|
||||
@@ -53,6 +63,59 @@ export default function NewIncidentScreen() {
|
||||
});
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
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) {
|
||||
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 (
|
||||
<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 (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: Colors.bg }}
|
||||
@@ -95,7 +214,7 @@ export default function NewIncidentScreen() {
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={LABEL_STYLE}>Title *</Text>
|
||||
{labelRow("Title *", "title")}
|
||||
<TextInput
|
||||
style={FIELD_STYLE}
|
||||
value={form.title}
|
||||
@@ -105,18 +224,50 @@ export default function NewIncidentScreen() {
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={LABEL_STYLE}>Service</Text>
|
||||
<Text style={[LABEL_STYLE, { marginBottom: 6 }]}>Service</Text>
|
||||
<TextInput
|
||||
style={FIELD_STYLE}
|
||||
value={form.service}
|
||||
onChangeText={set("service")}
|
||||
onFocus={() => 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 && (
|
||||
<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
|
||||
style={MONO_FIELD_STYLE}
|
||||
value={form.symptom}
|
||||
@@ -127,7 +278,7 @@ export default function NewIncidentScreen() {
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={LABEL_STYLE}>Root Cause</Text>
|
||||
{labelRow("Root Cause", "rootCause")}
|
||||
<TextInput
|
||||
style={MONO_FIELD_STYLE}
|
||||
value={form.rootCause}
|
||||
@@ -138,7 +289,7 @@ export default function NewIncidentScreen() {
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={LABEL_STYLE}>Fix Applied</Text>
|
||||
{labelRow("Fix Applied", "fix")}
|
||||
<TextInput
|
||||
style={MONO_FIELD_STYLE}
|
||||
value={form.fix}
|
||||
@@ -149,7 +300,7 @@ export default function NewIncidentScreen() {
|
||||
returnKeyType="done"
|
||||
/>
|
||||
|
||||
<Text style={LABEL_STYLE}>Tags</Text>
|
||||
<Text style={[LABEL_STYLE, { marginBottom: 6 }]}>Tags</Text>
|
||||
<TextInput
|
||||
style={FIELD_STYLE}
|
||||
value={tagInput}
|
||||
@@ -181,7 +332,7 @@ export default function NewIncidentScreen() {
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</View>
|
||||
@@ -198,10 +349,8 @@ export default function NewIncidentScreen() {
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}
|
||||
>
|
||||
{saving ? "Saving…" : "Save Incident"}
|
||||
<Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}>
|
||||
{saving ? "Saving…" : editId ? "Save Changes" : "Save Incident"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
|
||||
Reference in New Issue
Block a user