Initial scaffold: Expo SDK 56, expo-router, expo-sqlite, NativeWind
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
|
],
|
||||||
|
plugins: ["react-native-reanimated/plugin"],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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" });
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
Generated
+8356
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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">;
|
||||||
Reference in New Issue
Block a user