Initial scaffold: Expo SDK 56, expo-router, expo-sqlite, NativeWind

This commit is contained in:
2026-06-18 16:50:27 +02:00
parent f12febf122
commit 8388f49fd4
24 changed files with 10060 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "../global.css";
import { Colors } from "@/constants/theme";
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: Colors.surface },
headerTintColor: Colors.text1,
headerTitleStyle: { color: Colors.text1 },
contentStyle: { backgroundColor: Colors.bg },
animation: "slide_from_right",
}}
>
<Stack.Screen name="index" options={{ title: "SheetHappens" }} />
<Stack.Screen name="new" options={{ title: "New Incident", presentation: "modal" }} />
<Stack.Screen name="incident/[id]" options={{ title: "Incident" }} />
<Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
</Stack>
</GestureHandlerRootView>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useState } from "react";
import {
View,
Text,
ScrollView,
Pressable,
Alert,
Share,
} from "react-native";
import { useLocalSearchParams, router, useNavigation } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { getIncidentById, updateIncident } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown";
import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme";
const SECTION_LABEL = {
color: Colors.text2,
fontSize: 11,
fontWeight: "700" as const,
textTransform: "uppercase" as const,
letterSpacing: 0.8,
marginBottom: 6,
marginTop: 20,
} as const;
const SECTION_CONTENT = {
color: Colors.text1,
fontSize: 14,
fontFamily: "monospace",
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 12,
lineHeight: 20,
} as const;
export default function IncidentDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const navigation = useNavigation();
const [incident, setIncident] = useState<Incident | null>(null);
useEffect(() => {
if (id) {
getIncidentById(id).then((inc) => {
setIncident(inc);
if (inc) {
navigation.setOptions({ title: inc.title || "Incident" });
}
});
}
}, [id]);
async function handleResolve() {
if (!incident) return;
await updateIncident(incident.id, { status: "resolved" });
setIncident({ ...incident, status: "resolved" });
}
async function handleExport() {
if (!incident) return;
const md = renderMarkdown(incident);
try {
await Share.share({ message: md, title: incident.title });
} catch {
await Clipboard.setStringAsync(md);
Alert.alert("Copied", "Markdown copied to clipboard.");
}
}
if (!incident) {
return (
<View
style={{ flex: 1, backgroundColor: Colors.bg, alignItems: "center", justifyContent: "center" }}
>
<Text style={{ color: Colors.textDim }}>Loading</Text>
</View>
);
}
return (
<ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
>
{/* Status badge */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 }}>
<View
style={{
backgroundColor:
incident.status === "open" ? "#f59e0b20" : "#22c55e20",
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 4,
}}
>
<Text
style={{
color: incident.status === "open" ? Colors.warning : Colors.success,
fontSize: 12,
fontWeight: "700",
textTransform: "uppercase",
}}
>
{incident.status}
</Text>
</View>
{incident.service ? (
<Text style={{ color: Colors.textDim, fontSize: 12 }}>
{incident.service}
</Text>
) : null}
<Text style={{ color: Colors.textDim, fontSize: 12 }}>
{incident.createdAt.slice(0, 16).replace("T", " ")}
</Text>
</View>
<Text
style={{
color: Colors.text1,
fontSize: 22,
fontWeight: "700",
marginBottom: 4,
}}
>
{incident.title || "Untitled"}
</Text>
{incident.tags.length > 0 && (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 8 }}>
{incident.tags.map((tag) => (
<View
key={tag}
style={{
backgroundColor: Colors.surface2,
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 3,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12 }}>{tag}</Text>
</View>
))}
</View>
)}
<Text style={SECTION_LABEL}>Symptom</Text>
<Text style={SECTION_CONTENT}>{incident.symptom || "—"}</Text>
<Text style={SECTION_LABEL}>Root Cause</Text>
<Text style={SECTION_CONTENT}>{incident.rootCause || "—"}</Text>
<Text style={SECTION_LABEL}>Fix Applied</Text>
<Text style={SECTION_CONTENT}>{incident.fix || "—"}</Text>
{/* Actions */}
<View style={{ gap: 10, marginTop: 28 }}>
{incident.status === "open" && (
<Pressable
onPress={handleResolve}
style={{
backgroundColor: "#22c55e20",
borderWidth: 1,
borderColor: Colors.success,
borderRadius: 10,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: Colors.success, fontWeight: "700", fontSize: 15 }}>
Mark as Resolved
</Text>
</Pressable>
)}
<Pressable
onPress={handleExport}
style={{
backgroundColor: Colors.surface,
borderWidth: 1,
borderColor: Colors.border,
borderRadius: 10,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text1, fontWeight: "600", fontSize: 15 }}>
Export Markdown
</Text>
</Pressable>
<Pressable
onPress={() => router.push({ pathname: "/new", params: { editId: incident.id } })}
style={{
backgroundColor: Colors.surface,
borderWidth: 1,
borderColor: Colors.border,
borderRadius: 10,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text2, fontWeight: "600", fontSize: 15 }}>
Edit
</Text>
</Pressable>
</View>
</ScrollView>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useState } from "react";
import {
View,
Text,
FlatList,
Pressable,
ActivityIndicator,
} from "react-native";
import { router } from "expo-router";
import { getIncidents } from "@/lib/db";
import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme";
export default function HomeScreen() {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadIncidents();
}, []);
async function loadIncidents() {
setLoading(true);
const data = await getIncidents(10);
setIncidents(data);
setLoading(false);
}
return (
<View style={{ flex: 1, backgroundColor: Colors.bg }}>
{loading ? (
<ActivityIndicator
color={Colors.primary}
style={{ marginTop: 48 }}
/>
) : (
<FlatList
data={incidents}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 16, paddingBottom: 96 }}
ListEmptyComponent={
<Text
style={{
color: Colors.textDim,
textAlign: "center",
marginTop: 64,
fontSize: 15,
}}
>
No incidents. Press + to log one.
</Text>
}
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(`/incident/${item.id}`)}
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 16,
marginBottom: 10,
borderLeftWidth: 3,
borderLeftColor:
item.status === "open" ? Colors.warning : Colors.success,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 4,
}}
>
<Text
style={{
color: Colors.text1,
fontSize: 15,
fontWeight: "600",
flex: 1,
}}
numberOfLines={1}
>
{item.title || "Untitled"}
</Text>
<View
style={{
backgroundColor:
item.status === "open"
? "#f59e0b20"
: "#22c55e20",
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
marginLeft: 8,
}}
>
<Text
style={{
color:
item.status === "open"
? Colors.warning
: Colors.success,
fontSize: 11,
fontWeight: "700",
textTransform: "uppercase",
}}
>
{item.status}
</Text>
</View>
</View>
<Text
style={{ color: Colors.text2, fontSize: 12 }}
numberOfLines={1}
>
{item.service || "—"} · {item.createdAt.slice(0, 10)}
</Text>
</Pressable>
)}
/>
)}
{/* FAB */}
<Pressable
onPress={() => router.push("/new")}
style={{
position: "absolute",
bottom: 24,
right: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: Colors.primary,
alignItems: "center",
justifyContent: "center",
elevation: 6,
}}
>
<Text
style={{ color: "#fff", fontSize: 28, lineHeight: 32 }}
>
+
</Text>
</Pressable>
</View>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useState } from "react";
import {
View,
Text,
TextInput,
ScrollView,
Pressable,
KeyboardAvoidingView,
Platform,
Alert,
} from "react-native";
import { router } from "expo-router";
import { createIncident } from "@/lib/db";
import { Colors } from "@/constants/theme";
import type { IncidentDraft } from "@/types/incident";
const FIELD_STYLE = {
backgroundColor: Colors.surface,
borderColor: Colors.border,
borderWidth: 1,
borderRadius: 8,
color: Colors.text1,
padding: 12,
fontSize: 14,
marginBottom: 16,
} as const;
const MONO_FIELD_STYLE = {
...FIELD_STYLE,
fontFamily: "monospace",
minHeight: 80,
textAlignVertical: "top" as const,
} as const;
const LABEL_STYLE = {
color: Colors.text2,
fontSize: 12,
fontWeight: "600" as const,
textTransform: "uppercase" as const,
letterSpacing: 0.5,
marginBottom: 6,
} as const;
export default function NewIncidentScreen() {
const [form, setForm] = useState<IncidentDraft>({
title: "",
service: "",
symptom: "",
rootCause: "",
fix: "",
status: "open",
tags: [],
});
const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false);
function set(key: keyof IncidentDraft) {
return (value: string) => setForm((f) => ({ ...f, [key]: value }));
}
function handleTagInputEnd() {
const trimmed = tagInput.trim();
if (trimmed && !form.tags.includes(trimmed)) {
setForm((f) => ({ ...f, tags: [...f.tags, trimmed] }));
}
setTagInput("");
}
function removeTag(tag: string) {
setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) }));
}
async function handleSave() {
if (!form.title.trim()) {
Alert.alert("Required", "Title is required.");
return;
}
setSaving(true);
try {
await createIncident(form);
router.back();
} catch (e) {
Alert.alert("Error", "Failed to save incident.");
} finally {
setSaving(false);
}
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: Colors.bg }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
keyboardShouldPersistTaps="handled"
>
<Text style={LABEL_STYLE}>Title *</Text>
<TextInput
style={FIELD_STYLE}
value={form.title}
onChangeText={set("title")}
placeholder="e.g. Ghost blog down after k3s node drain"
placeholderTextColor={Colors.textDim}
returnKeyType="next"
/>
<Text style={LABEL_STYLE}>Service</Text>
<TextInput
style={FIELD_STYLE}
value={form.service}
onChangeText={set("service")}
placeholder="e.g. ghost-blog, k3s-cluster, haproxy"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
returnKeyType="next"
/>
<Text style={LABEL_STYLE}>Symptom</Text>
<TextInput
style={MONO_FIELD_STYLE}
value={form.symptom}
onChangeText={set("symptom")}
placeholder="What was observed?"
placeholderTextColor={Colors.textDim}
multiline
returnKeyType="next"
/>
<Text style={LABEL_STYLE}>Root Cause</Text>
<TextInput
style={MONO_FIELD_STYLE}
value={form.rootCause}
onChangeText={set("rootCause")}
placeholder="Why did it happen?"
placeholderTextColor={Colors.textDim}
multiline
returnKeyType="next"
/>
<Text style={LABEL_STYLE}>Fix Applied</Text>
<TextInput
style={MONO_FIELD_STYLE}
value={form.fix}
onChangeText={set("fix")}
placeholder="What did you do to fix it?"
placeholderTextColor={Colors.textDim}
multiline
returnKeyType="done"
/>
<Text style={LABEL_STYLE}>Tags</Text>
<TextInput
style={FIELD_STYLE}
value={tagInput}
onChangeText={setTagInput}
onSubmitEditing={handleTagInputEnd}
onBlur={handleTagInputEnd}
placeholder="Type tag and press Enter"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
returnKeyType="done"
blurOnSubmit={false}
/>
{form.tags.length > 0 && (
<View
style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 16 }}
>
{form.tags.map((tag) => (
<Pressable
key={tag}
onPress={() => removeTag(tag)}
style={{
backgroundColor: Colors.surface2,
borderRadius: 4,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: "row",
alignItems: "center",
gap: 6,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13 }}>{tag}</Text>
<Text style={{ color: Colors.textDim, fontSize: 12 }}></Text>
</Pressable>
))}
</View>
)}
<Pressable
onPress={handleSave}
disabled={saving}
style={{
backgroundColor: saving ? Colors.primaryDim : Colors.primary,
borderRadius: 10,
padding: 16,
alignItems: "center",
marginTop: 8,
}}
>
<Text
style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}
>
{saving ? "Saving…" : "Save Incident"}
</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useState } from "react";
import { View, Text, Pressable, SafeAreaView } from "react-native";
import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { Colors } from "@/constants/theme";
const STEPS = [
{
key: "homeView",
question: "When you open the app…",
options: [
{ value: "dashboard", label: "Show recent incidents", desc: "Dashboard with the last 10 events" },
{ value: "capture", label: "Go straight to capture", desc: "New incident form opens immediately" },
],
},
{
key: "inputMode",
question: "How do you prefer to input incidents?",
options: [
{ value: "form", label: "Typed form", desc: "Fill in each field manually" },
{ value: "voice", label: "Voice dictation", desc: "Speak and let the app transcribe" },
],
},
{
key: "gitea",
question: "Do you want to push incidents to Gitea?",
options: [
{ value: "later", label: "Configure later", desc: "Set it up in Settings anytime" },
{ value: "now", label: "Set up now", desc: "You'll be redirected to Settings" },
],
},
] as const;
export default function OnboardingScreen() {
const [step, setStep] = useState(0);
const [answers, setAnswers] = useState<Record<string, string>>({});
const current = STEPS[step];
async function choose(value: string) {
const next = { ...answers, [current.key]: value };
setAnswers(next);
if (step < STEPS.length - 1) {
setStep((s) => s + 1);
} else {
await SecureStore.setItemAsync("pref_home_view", next.homeView ?? "dashboard");
await SecureStore.setItemAsync("pref_input_mode", next.inputMode ?? "form");
await SecureStore.setItemAsync("onboarding_done", "true");
if (next.gitea === "now") {
router.replace("/settings");
} else {
router.replace("/");
}
}
}
const progress = ((step + 1) / STEPS.length) * 100;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: Colors.bg }}>
<View style={{ flex: 1, padding: 24, justifyContent: "center" }}>
{/* Progress bar */}
<View
style={{
height: 3,
backgroundColor: Colors.border,
borderRadius: 2,
marginBottom: 48,
}}
>
<View
style={{
height: 3,
width: `${progress}%`,
backgroundColor: Colors.primary,
borderRadius: 2,
}}
/>
</View>
<Text
style={{
color: Colors.textDim,
fontSize: 12,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 12,
}}
>
{step + 1} / {STEPS.length}
</Text>
<Text
style={{
color: Colors.text1,
fontSize: 26,
fontWeight: "700",
marginBottom: 32,
lineHeight: 34,
}}
>
{current.question}
</Text>
<View style={{ gap: 12 }}>
{current.options.map((opt) => (
<Pressable
key={opt.value}
onPress={() => choose(opt.value)}
style={{
backgroundColor: Colors.surface,
borderRadius: 12,
padding: 20,
borderWidth: 1,
borderColor: Colors.border,
}}
>
<Text
style={{
color: Colors.text1,
fontSize: 17,
fontWeight: "600",
marginBottom: 4,
}}
>
{opt.label}
</Text>
<Text style={{ color: Colors.text2, fontSize: 14 }}>
{opt.desc}
</Text>
</Pressable>
))}
</View>
</View>
</SafeAreaView>
);
}
+303
View File
@@ -0,0 +1,303 @@
import { useState, useEffect } from "react";
import {
View,
Text,
TextInput,
ScrollView,
Switch,
Pressable,
Alert,
} from "react-native";
import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown";
const KEYS = {
INPUT_MODE: "pref_input_mode",
HOME_VIEW: "pref_home_view",
AI_ENABLED: "pref_ai_enabled",
AI_KEY: "pref_ai_key",
GITEA_URL: "gitea_url",
GITEA_TOKEN: "gitea_token",
GITEA_OWNER: "gitea_owner",
GITEA_REPO: "gitea_repo",
MD_TEMPLATE: "md_template",
} as const;
const LABEL = {
color: Colors.text2,
fontSize: 11,
fontWeight: "700" as const,
textTransform: "uppercase" as const,
letterSpacing: 0.6,
marginBottom: 6,
} as const;
const SECTION_TITLE = {
color: Colors.text1,
fontSize: 16,
fontWeight: "700" as const,
marginTop: 28,
marginBottom: 12,
} as const;
const INPUT = {
backgroundColor: Colors.surface,
borderColor: Colors.border,
borderWidth: 1,
borderRadius: 8,
color: Colors.text1,
padding: 12,
fontSize: 14,
marginBottom: 14,
fontFamily: "monospace",
} as const;
export default function SettingsScreen() {
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false);
const [aiKey, setAiKey] = useState("");
const [giteaUrl, setGiteaUrl] = useState("");
const [giteaToken, setGiteaToken] = useState("");
const [giteaOwner, setGiteaOwner] = useState("");
const [giteaRepo, setGiteaRepo] = useState("");
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
const [saved, setSaved] = useState(false);
useEffect(() => {
(async () => {
const [im, hv, ai, key, gu, gt, go, gr, tpl] = await Promise.all([
SecureStore.getItemAsync(KEYS.INPUT_MODE),
SecureStore.getItemAsync(KEYS.HOME_VIEW),
SecureStore.getItemAsync(KEYS.AI_ENABLED),
SecureStore.getItemAsync(KEYS.AI_KEY),
SecureStore.getItemAsync(KEYS.GITEA_URL),
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
SecureStore.getItemAsync(KEYS.GITEA_REPO),
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
]);
if (im) setInputMode(im as "voice" | "form");
if (hv) setHomeView(hv as "dashboard" | "capture");
if (ai) setAiEnabled(ai === "true");
if (key) setAiKey(key);
if (gu) setGiteaUrl(gu);
if (gt) setGiteaToken(gt);
if (go) setGiteaOwner(go);
if (gr) setGiteaRepo(gr);
if (tpl) setMdTemplate(tpl);
})();
}, []);
async function handleSave() {
await Promise.all([
SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode),
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner),
SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
function ToggleRow({
label,
value,
onValueChange,
}: {
label: string;
value: boolean;
onValueChange: (v: boolean) => void;
}) {
return (
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 14,
marginBottom: 10,
}}
>
<Text style={{ color: Colors.text1, fontSize: 15 }}>{label}</Text>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ true: Colors.primary, false: Colors.border }}
thumbColor="#fff"
/>
</View>
);
}
function SegmentRow({
label,
options,
value,
onChange,
}: {
label: string;
options: { key: string; label: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<View style={{ marginBottom: 16 }}>
<Text style={LABEL}>{label}</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
{options.map((opt) => (
<Pressable
key={opt.key}
onPress={() => onChange(opt.key)}
style={{
flex: 1,
backgroundColor:
value === opt.key ? Colors.primary : Colors.surface,
borderRadius: 8,
padding: 12,
alignItems: "center",
borderWidth: 1,
borderColor:
value === opt.key ? Colors.primary : Colors.border,
}}
>
<Text
style={{
color: value === opt.key ? "#fff" : Colors.text2,
fontWeight: "600",
fontSize: 13,
}}
>
{opt.label}
</Text>
</Pressable>
))}
</View>
</View>
);
}
return (
<ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
keyboardShouldPersistTaps="handled"
>
<Text style={SECTION_TITLE}>Preferences</Text>
<SegmentRow
label="Default Input"
value={inputMode}
onChange={(v) => setInputMode(v as "voice" | "form")}
options={[
{ key: "form", label: "Form" },
{ key: "voice", label: "Voice" },
]}
/>
<SegmentRow
label="Home View"
value={homeView}
onChange={(v) => setHomeView(v as "dashboard" | "capture")}
options={[
{ key: "dashboard", label: "Dashboard" },
{ key: "capture", label: "Quick Capture" },
]}
/>
<Text style={SECTION_TITLE}>AI (optional)</Text>
<ToggleRow
label="Enable AI assistance"
value={aiEnabled}
onValueChange={setAiEnabled}
/>
{aiEnabled && (
<>
<Text style={LABEL}>API Key</Text>
<TextInput
style={INPUT}
value={aiKey}
onChangeText={setAiKey}
placeholder="sk-..."
placeholderTextColor={Colors.textDim}
secureTextEntry
autoCapitalize="none"
/>
</>
)}
<Text style={SECTION_TITLE}>Gitea</Text>
<Text style={LABEL}>Instance URL</Text>
<TextInput
style={INPUT}
value={giteaUrl}
onChangeText={setGiteaUrl}
placeholder="https://homegit.gyozamancave.fr"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
keyboardType="url"
/>
<Text style={LABEL}>Token</Text>
<TextInput
style={INPUT}
value={giteaToken}
onChangeText={setGiteaToken}
placeholder="Bearer token"
placeholderTextColor={Colors.textDim}
secureTextEntry
autoCapitalize="none"
/>
<Text style={LABEL}>Owner</Text>
<TextInput
style={INPUT}
value={giteaOwner}
onChangeText={setGiteaOwner}
placeholder="username or org"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
/>
<Text style={LABEL}>Repository</Text>
<TextInput
style={INPUT}
value={giteaRepo}
onChangeText={setGiteaRepo}
placeholder="incidents"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
/>
<Text style={SECTION_TITLE}>Markdown Template</Text>
<TextInput
style={{ ...INPUT, minHeight: 200, textAlignVertical: "top" }}
value={mdTemplate}
onChangeText={setMdTemplate}
multiline
placeholder={DEFAULT_TEMPLATE}
placeholderTextColor={Colors.textDim}
/>
<Pressable
onPress={handleSave}
style={{
backgroundColor: saved ? Colors.success : Colors.primary,
borderRadius: 10,
padding: 16,
alignItems: "center",
marginTop: 8,
}}
>
<Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}>
{saved ? "Saved ✓" : "Save Settings"}
</Text>
</Pressable>
</ScrollView>
);
}