Initial scaffold: Expo SDK 56, expo-router, expo-sqlite, NativeWind
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user