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
+13
View File
@@ -0,0 +1,13 @@
node_modules/
.expo/
dist/
android/
ios/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
expo-env.d.ts
+44
View File
@@ -0,0 +1,44 @@
{
"expo": {
"name": "SheetHappens",
"slug": "sheethappens",
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "dark",
"backgroundColor": "#0f1117",
"scheme": "sheethappens",
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0f1117"
},
"package": "fr.gyozamancave.sheethappens",
"permissions": [
"android.permission.RECORD_AUDIO"
]
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-sqlite",
{
"enableFTS": false,
"useSQLCipher": false
}
],
[
"expo-speech-recognition",
{
"microphonePermission": "Allow SheetHappens to use the microphone for voice input."
}
]
],
"experiments": {
"typedRoutes": true
},
"web": {
"bundler": "metro"
}
}
}
+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>
);
}
+9
View File
@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
],
plugins: ["react-native-reanimated/plugin"],
};
};
+37
View File
@@ -0,0 +1,37 @@
export const Colors = {
bg: "#0f1117",
surface: "#1a1d27",
surface2: "#252836",
primary: "#6366f1",
primaryDim: "#4338ca",
success: "#22c55e",
warning: "#f59e0b",
danger: "#ef4444",
text1: "#f1f5f9",
text2: "#94a3b8",
textDim: "#64748b",
border: "#2d3147",
} as const;
export const FontFamily = {
sans: undefined,
mono: "SpaceMono",
} as const;
export const FontSize = {
xs: 11,
sm: 13,
base: 15,
lg: 17,
xl: 20,
"2xl": 24,
"3xl": 30,
} as const;
export const Spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
} as const;
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+45
View File
@@ -0,0 +1,45 @@
import { useState, useCallback } from "react";
import {
getIncidents,
createIncident,
updateIncident,
deleteIncident,
} from "@/lib/db";
import type { Incident, IncidentDraft } from "@/types/incident";
export function useIncidents() {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async (limit = 10) => {
setLoading(true);
const data = await getIncidents(limit);
setIncidents(data);
setLoading(false);
}, []);
const add = useCallback(async (draft: IncidentDraft) => {
const created = await createIncident(draft);
setIncidents((prev) => [created, ...prev]);
return created;
}, []);
const update = useCallback(
async (id: string, updates: Partial<IncidentDraft>) => {
await updateIncident(id, updates);
setIncidents((prev) =>
prev.map((inc) =>
inc.id === id ? { ...inc, ...updates, updatedAt: new Date().toISOString() } : inc
)
);
},
[]
);
const remove = useCallback(async (id: string) => {
await deleteIncident(id);
setIncidents((prev) => prev.filter((inc) => inc.id !== id));
}, []);
return { incidents, loading, refresh, add, update, remove };
}
+37
View File
@@ -0,0 +1,37 @@
import { useState, useEffect } from "react";
import * as SecureStore from "expo-secure-store";
export interface Settings {
homeView: "dashboard" | "capture";
inputMode: "form" | "voice";
onboardingDone: boolean;
}
const DEFAULTS: Settings = {
homeView: "dashboard",
inputMode: "form",
onboardingDone: false,
};
export function useSettings() {
const [settings, setSettings] = useState<Settings>(DEFAULTS);
const [ready, setReady] = useState(false);
useEffect(() => {
(async () => {
const [hv, im, od] = await Promise.all([
SecureStore.getItemAsync("pref_home_view"),
SecureStore.getItemAsync("pref_input_mode"),
SecureStore.getItemAsync("onboarding_done"),
]);
setSettings({
homeView: (hv as Settings["homeView"]) ?? DEFAULTS.homeView,
inputMode: (im as Settings["inputMode"]) ?? DEFAULTS.inputMode,
onboardingDone: od === "true",
});
setReady(true);
})();
}, []);
return { settings, ready };
}
+55
View File
@@ -0,0 +1,55 @@
import { useState, useCallback, useRef } from "react";
import {
ExpoSpeechRecognitionModule,
useSpeechRecognitionEvent,
} from "expo-speech-recognition";
export type VoiceState = "idle" | "listening" | "processing" | "error";
export function useVoice() {
const [state, setState] = useState<VoiceState>("idle");
const [transcript, setTranscript] = useState("");
const [error, setError] = useState<string | null>(null);
useSpeechRecognitionEvent("start", () => setState("listening"));
useSpeechRecognitionEvent("end", () => {
setState((s) => (s === "listening" ? "processing" : s));
});
useSpeechRecognitionEvent("result", (event) => {
const best = event.results[0]?.transcript ?? "";
setTranscript(best);
if (event.isFinal) setState("idle");
});
useSpeechRecognitionEvent("error", (event) => {
setError(event.error ?? "Voice recognition failed");
setState("error");
});
const start = useCallback(async (lang = "fr-FR") => {
setError(null);
setTranscript("");
const { granted } =
await ExpoSpeechRecognitionModule.requestPermissionsAsync();
if (!granted) {
setError("Microphone permission denied");
setState("error");
return;
}
ExpoSpeechRecognitionModule.start({ lang, interimResults: true });
}, []);
const stop = useCallback(() => {
ExpoSpeechRecognitionModule.stop();
}, []);
const reset = useCallback(() => {
setTranscript("");
setError(null);
setState("idle");
}, []);
return { state, transcript, error, start, stop, reset };
}
+191
View File
@@ -0,0 +1,191 @@
import * as SQLite from "expo-sqlite";
import type { Incident, IncidentDraft, IncidentStatus } from "@/types/incident";
const DB_NAME = "sheethappens.db";
let dbInstance: SQLite.SQLiteDatabase | null = null;
async function getDb(): Promise<SQLite.SQLiteDatabase> {
if (dbInstance) return dbInstance;
dbInstance = await SQLite.openDatabaseAsync(DB_NAME);
await migrate(dbInstance);
return dbInstance;
}
async function migrate(db: SQLite.SQLiteDatabase): Promise<void> {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY NOT NULL
);
`);
const row = await db.getFirstAsync<{ version: number }>(
"SELECT MAX(version) as version FROM schema_version"
);
const current = row?.version ?? 0;
if (current < 1) {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS incidents (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL DEFAULT '',
service TEXT NOT NULL DEFAULT '',
symptom TEXT NOT NULL DEFAULT '',
root_cause TEXT NOT NULL DEFAULT '',
fix TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_incidents_status
ON incidents(status);
CREATE INDEX IF NOT EXISTS idx_incidents_created_at
ON incidents(created_at DESC);
INSERT INTO schema_version (version) VALUES (1);
`);
}
}
// --- row mapper ---
interface IncidentRow {
id: string;
title: string;
service: string;
symptom: string;
root_cause: string;
fix: string;
status: IncidentStatus;
created_at: string;
updated_at: string;
tags: string;
}
function rowToIncident(row: IncidentRow): Incident {
return {
id: row.id,
title: row.title,
service: row.service,
symptom: row.symptom,
rootCause: row.root_cause,
fix: row.fix,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
tags: JSON.parse(row.tags) as string[],
};
}
// --- CRUD ---
export async function createIncident(draft: IncidentDraft): Promise<Incident> {
const db = await getDb();
const id = generateId();
const now = new Date().toISOString();
await db.runAsync(
`INSERT INTO incidents
(id, title, service, symptom, root_cause, fix, status, created_at, updated_at, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
draft.title,
draft.service,
draft.symptom,
draft.rootCause,
draft.fix,
draft.status,
now,
now,
JSON.stringify(draft.tags),
]
);
return {
...draft,
id,
createdAt: now,
updatedAt: now,
};
}
export async function getIncidents(limit = 50): Promise<Incident[]> {
const db = await getDb();
const rows = await db.getAllAsync<IncidentRow>(
"SELECT * FROM incidents ORDER BY created_at DESC LIMIT ?",
[limit]
);
return rows.map(rowToIncident);
}
export async function getIncidentById(id: string): Promise<Incident | null> {
const db = await getDb();
const row = await db.getFirstAsync<IncidentRow>(
"SELECT * FROM incidents WHERE id = ?",
[id]
);
return row ? rowToIncident(row) : null;
}
export async function updateIncident(
id: string,
updates: Partial<IncidentDraft>
): Promise<void> {
const db = await getDb();
const now = new Date().toISOString();
const fields: string[] = [];
const values: (string | null)[] = [];
if (updates.title !== undefined) { fields.push("title = ?"); values.push(updates.title); }
if (updates.service !== undefined) { fields.push("service = ?"); values.push(updates.service); }
if (updates.symptom !== undefined) { fields.push("symptom = ?"); values.push(updates.symptom); }
if (updates.rootCause !== undefined) { fields.push("root_cause = ?"); values.push(updates.rootCause); }
if (updates.fix !== undefined) { fields.push("fix = ?"); values.push(updates.fix); }
if (updates.status !== undefined) { fields.push("status = ?"); values.push(updates.status); }
if (updates.tags !== undefined) { fields.push("tags = ?"); values.push(JSON.stringify(updates.tags)); }
if (fields.length === 0) return;
fields.push("updated_at = ?");
values.push(now);
values.push(id);
await db.runAsync(
`UPDATE incidents SET ${fields.join(", ")} WHERE id = ?`,
values
);
}
export async function deleteIncident(id: string): Promise<void> {
const db = await getDb();
await db.runAsync("DELETE FROM incidents WHERE id = ?", [id]);
}
export async function getDistinctServices(): Promise<string[]> {
const db = await getDb();
const rows = await db.getAllAsync<{ service: string }>(
"SELECT DISTINCT service FROM incidents WHERE service != '' ORDER BY service"
);
return rows.map((r) => r.service);
}
// --- UUID v4 (no external dep) ---
function generateId(): string {
const hex = "0123456789abcdef";
let uuid = "";
for (let i = 0; i < 36; i++) {
if (i === 8 || i === 13 || i === 18 || i === 23) {
uuid += "-";
} else if (i === 14) {
uuid += "4";
} else if (i === 19) {
uuid += hex[(Math.random() * 4) | 8];
} else {
uuid += hex[(Math.random() * 16) | 0];
}
}
return uuid;
}
+75
View File
@@ -0,0 +1,75 @@
import { incidentToFilename, renderMarkdown } from "./markdown";
import type { Incident } from "@/types/incident";
export interface GiteaConfig {
url: string;
token: string;
owner: string;
repo: string;
}
export interface PushResult {
success: boolean;
url?: string;
error?: string;
}
export async function pushIncidentToGitea(
incident: Incident,
config: GiteaConfig,
markdownTemplate?: string
): Promise<PushResult> {
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = config.url.replace(/\/$/, "");
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
// Check if file already exists to get its SHA (required for updates)
const existingResponse = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
let sha: string | undefined;
if (existingResponse.ok) {
const existing = await existingResponse.json() as { sha?: string };
sha = existing.sha;
}
const body: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
};
if (sha) body.sha = sha;
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.text();
return { success: false, error: `HTTP ${response.status}: ${err}` };
}
const data = await response.json() as { content?: { html_url?: string } };
return {
success: true,
url: data.content?.html_url,
};
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : String(e),
};
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { Incident } from "@/types/incident";
const DEFAULT_TEMPLATE = `# [TITRE]
**Date**: [DATE ISO]
**Service**: [SERVICE]
**Status**: [STATUS]
**Tags**: [TAGS]
## Symptom
[SYMPTOM]
## Root Cause
[ROOT_CAUSE]
## Fix Applied
[FIX]
`;
export function renderMarkdown(
incident: Incident,
template: string = DEFAULT_TEMPLATE
): string {
const tagList =
incident.tags.length > 0 ? incident.tags.join(", ") : "—";
return template
.replace("[TITRE]", incident.title || "Untitled")
.replace("[DATE ISO]", incident.createdAt)
.replace("[SERVICE]", incident.service || "—")
.replace("[STATUS]", incident.status)
.replace("[TAGS]", tagList)
.replace("[SYMPTOM]", incident.symptom || "—")
.replace("[ROOT_CAUSE]", incident.rootCause || "—")
.replace("[FIX]", incident.fix || "—");
}
export function incidentToFilename(incident: Incident): string {
const date = incident.createdAt.slice(0, 10);
const slug = incident.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50);
return `incidents/${date}-${slug || "incident"}.md`;
}
export { DEFAULT_TEMPLATE };
+6
View File
@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css" });
+1
View File
@@ -0,0 +1 @@
/// <reference types="nativewind/types" />
+8356
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "sheethappens",
"main": "expo-router/entry",
"version": "0.1.0",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios"
},
"dependencies": {
"expo": "~56.0.12",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.18",
"expo-linking": "~56.0.14",
"expo-router": "~56.2.11",
"expo-secure-store": "~56.0.4",
"expo-sharing": "~56.0.18",
"expo-speech-recognition": "~56.0.1",
"expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4",
"nativewind": "~4.2.5",
"react": "19.2.7",
"react-native": "0.85.3",
"react-native-gesture-handler": "~3.0.2",
"react-native-reanimated": "~4.4.1",
"react-native-safe-area-context": "~5.8.0",
"react-native-screens": "~4.25.2",
"tailwindcss": "~3.4.19"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@expo/metro-config": "~56.0.14",
"@types/react": "~19.2.0",
"typescript": "~5.8.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./components/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
bg: "#0f1117",
surface: "#1a1d27",
"surface-2": "#252836",
primary: "#6366f1",
"primary-dim": "#4338ca",
success: "#22c55e",
warning: "#f59e0b",
danger: "#ef4444",
"text-1": "#f1f5f9",
"text-2": "#94a3b8",
"text-dim": "#64748b",
border: "#2d3147",
},
},
},
plugins: [],
};
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts",
"expo-env.d.ts"
]
}
+16
View File
@@ -0,0 +1,16 @@
export type IncidentStatus = "open" | "resolved";
export interface Incident {
id: string;
title: string;
service: string;
symptom: string;
rootCause: string;
fix: string;
status: IncidentStatus;
createdAt: string;
updatedAt: string;
tags: string[];
}
export type IncidentDraft = Omit<Incident, "id" | "createdAt" | "updatedAt">;