4 Commits

Author SHA1 Message Date
billisdead fee283b5d6 feat: apply Charbon·Lime design system, add gear icon, multi-provider git settings
Release APK / build (push) Has been cancelled
- Replace app icon with new Charbon·Lime brand assets (icon.png + adaptive-icon.png)
- Apply design tokens to theme.ts: bg #1B2433, primary/success #88D656 (lime), text1 #EEF2F5 (cream)
- Add ⚙ gear button (lime) in HomeScreen header → navigates to /settings
- Expand settings: git provider selector (Gitea / GitHub / GitLab) with per-provider fields
- Add lib/git.ts: unified pushIncidentToGit() for Gitea, GitHub (PUT), and GitLab APIs
- Update incident detail screen to use lib/git.ts and dynamic provider routing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:00:49 +02:00
billisdead 67c132d88b fix: add mic to service field, refresh list on screen focus
Release APK / build (push) Has been cancelled
new.tsx: service field was missing its mic button — VoiceField type
excluded "service" and the label used a plain Text instead of labelRow().

index.tsx: incident list only loaded on mount; router.back() does not
remount the screen so new incidents never appeared. useFocusEffect
replaces the one-shot useEffect so the list reloads on every focus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:51:43 +02:00
billisdead f1a4b58234 refactor: remove crash reporter from _layout.tsx
Release APK / build (push) Has been cancelled
CrashBoundary and ErrorUtils debug handler were temporary scaffolding
to identify the launch crash (react version mismatch). Removing now
that the root cause is fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:50:15 +02:00
billisdead dca76ea394 fix: add npm overrides to align react + react-dom to 19.2.3
Release APK / build (push) Has been cancelled
react-dom@19.2.7 has a hard peer dep on react@^19.2.7, breaking
npm ci when react is pinned to 19.2.3. npm overrides force both
packages to 19.2.3 so the lock file resolves cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:20:49 +02:00
12 changed files with 514 additions and 189 deletions
+3 -2
View File
@@ -5,12 +5,13 @@
"version": "0.1.0", "version": "0.1.0",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"backgroundColor": "#0f1117", "icon": "./assets/icon.png",
"backgroundColor": "#1B2433",
"scheme": "sheethappens", "scheme": "sheethappens",
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0f1117" "backgroundColor": "#1B2433"
}, },
"package": "fr.gyozamancave.sheethappens", "package": "fr.gyozamancave.sheethappens",
"permissions": [ "permissions": [
+32 -77
View File
@@ -1,55 +1,14 @@
import { useEffect, useState, Component } from "react"; import { useEffect } from "react";
import type { ReactNode } from "react"; import { Pressable, Text } from "react-native";
import { Stack, router } from "expo-router"; import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { View, Text, ScrollView } from "react-native";
import "../global.css"; import "../global.css";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
// Catch JS errors in release mode and show them on-screen instead of
// silently crashing to desktop. Remove once the crash is identified.
class CrashBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state = { error: null };
static getDerivedStateFromError(e: Error) {
return { error: e };
}
render() {
const { error } = this.state;
if (error) {
return (
<View style={{ flex: 1, backgroundColor: "#0f1117", padding: 20, paddingTop: 60 }}>
<Text style={{ color: "#ef4444", fontSize: 16, fontWeight: "700", marginBottom: 12 }}>
CRASH copie ce texte
</Text>
<ScrollView>
<Text style={{ color: "#fff", fontSize: 11, fontFamily: "monospace" }}>
{String(error)}{"\n\n"}{(error as any).stack}
</Text>
</ScrollView>
</View>
);
}
return this.props.children;
}
}
export default function RootLayout() { export default function RootLayout() {
const { settings, ready } = useSettings(); const { settings, ready } = useSettings();
const [jsError, setJsError] = useState<string | null>(null);
useEffect(() => {
const prev = (ErrorUtils as any).getGlobalHandler();
(ErrorUtils as any).setGlobalHandler((e: Error, isFatal: boolean) => {
if (isFatal) setJsError(`${String(e)}\n\n${(e as any).stack ?? ""}`);
prev?.(e, isFatal);
});
return () => (ErrorUtils as any).setGlobalHandler(prev);
}, []);
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
@@ -58,41 +17,37 @@ export default function RootLayout() {
} }
}, [ready, settings.onboardingDone]); }, [ready, settings.onboardingDone]);
if (jsError) {
return (
<View style={{ flex: 1, backgroundColor: "#0f1117", padding: 20, paddingTop: 60 }}>
<Text style={{ color: "#ef4444", fontSize: 16, fontWeight: "700", marginBottom: 12 }}>
FATAL JS ERROR copie ce texte
</Text>
<ScrollView>
<Text style={{ color: "#fff", fontSize: 11, fontFamily: "monospace" }}>
{jsError}
</Text>
</ScrollView>
</View>
);
}
return ( return (
<CrashBoundary> <GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}> <StatusBar style="light" />
<StatusBar style="light" /> <Stack
<Stack screenOptions={{
screenOptions={{ headerStyle: { backgroundColor: Colors.surface },
headerStyle: { backgroundColor: Colors.surface }, headerTintColor: Colors.text1,
headerTintColor: Colors.text1, headerTitleStyle: { color: Colors.text1 },
headerTitleStyle: { color: Colors.text1 }, contentStyle: { backgroundColor: Colors.bg },
contentStyle: { backgroundColor: Colors.bg }, animation: "slide_from_right",
animation: "slide_from_right", }}
>
<Stack.Screen
name="index"
options={{
title: "SheetHappens",
headerRight: () => (
<Pressable
onPress={() => router.push("/settings")}
style={{ paddingLeft: 12, paddingRight: 4, paddingVertical: 6 }}
>
<Text style={{ color: Colors.primary, fontSize: 22, lineHeight: 26 }}></Text>
</Pressable>
),
}} }}
> />
<Stack.Screen name="index" options={{ title: "SheetHappens" }} /> <Stack.Screen name="new" options={{ title: "New Incident", presentation: "modal" }} />
<Stack.Screen name="new" options={{ title: "New Incident", presentation: "modal" }} /> <Stack.Screen name="incident/[id]" options={{ title: "Incident" }} />
<Stack.Screen name="incident/[id]" options={{ title: "Incident" }} /> <Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="settings" options={{ title: "Settings" }} /> <Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="onboarding" options={{ headerShown: false }} /> </Stack>
</Stack> </GestureHandlerRootView>
</GestureHandlerRootView>
</CrashBoundary>
); );
} }
+53 -18
View File
@@ -12,7 +12,8 @@ import * as Clipboard from "expo-clipboard";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident } from "@/lib/db"; import { getIncidentById, updateIncident } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown"; import { renderMarkdown } from "@/lib/markdown";
import { pushIncidentToGitea } from "@/lib/gitea"; import { pushIncidentToGit } from "@/lib/git";
import type { GitProvider } from "@/lib/git";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
@@ -59,28 +60,62 @@ export default function IncidentDetailScreen() {
setIncident({ ...incident, status: "resolved" }); setIncident({ ...incident, status: "resolved" });
} }
async function handleGiteaPush() { async function handleGitPush() {
if (!incident) return; if (!incident) return;
const [url, token, owner, repo, template] = await Promise.all([ const [provider, template, giteaUrl, giteaToken, giteaOwner, giteaRepo, githubToken, githubOwner, githubRepo, gitlabUrl, gitlabToken, gitlabOwner, gitlabRepo] =
SecureStore.getItemAsync("gitea_url"), await Promise.all([
SecureStore.getItemAsync("gitea_token"), SecureStore.getItemAsync("git_provider"),
SecureStore.getItemAsync("gitea_owner"), SecureStore.getItemAsync("md_template"),
SecureStore.getItemAsync("gitea_repo"), SecureStore.getItemAsync("gitea_url"),
SecureStore.getItemAsync("md_template"), SecureStore.getItemAsync("gitea_token"),
]); SecureStore.getItemAsync("gitea_owner"),
if (!url || !token || !owner || !repo) { SecureStore.getItemAsync("gitea_repo"),
Alert.alert("Gitea not configured", "Set up Gitea in Settings first."); SecureStore.getItemAsync("github_token"),
SecureStore.getItemAsync("github_owner"),
SecureStore.getItemAsync("github_repo"),
SecureStore.getItemAsync("gitlab_url"),
SecureStore.getItemAsync("gitlab_token"),
SecureStore.getItemAsync("gitlab_owner"),
SecureStore.getItemAsync("gitlab_repo"),
]);
const p = (provider ?? "gitea") as GitProvider;
let url: string | undefined;
let token: string | null;
let owner: string | null;
let repo: string | null;
if (p === "gitea") {
url = giteaUrl ?? undefined;
token = giteaToken;
owner = giteaOwner;
repo = giteaRepo;
} else if (p === "github") {
token = githubToken;
owner = githubOwner;
repo = githubRepo;
} else {
url = gitlabUrl ?? undefined;
token = gitlabToken;
owner = gitlabOwner;
repo = gitlabRepo;
}
if (!token || !owner || !repo) {
Alert.alert("Git not configured", `Set up ${p.charAt(0).toUpperCase() + p.slice(1)} in Settings first.`);
return; return;
} }
setPushing(true); setPushing(true);
const result = await pushIncidentToGitea( const result = await pushIncidentToGit(
incident, incident,
{ url, token, owner, repo }, { provider: p, url, token, owner, repo },
template ?? undefined template ?? undefined
); );
setPushing(false); setPushing(false);
if (result.success) { if (result.success) {
Alert.alert("Pushed", result.url ? `${result.url}` : "Push successful."); Alert.alert("Pushed", result.url ?? "Push successful.");
} else { } else {
Alert.alert("Push failed", result.error ?? "Unknown error"); Alert.alert("Push failed", result.error ?? "Unknown error");
} }
@@ -117,7 +152,7 @@ export default function IncidentDetailScreen() {
<View <View
style={{ style={{
backgroundColor: backgroundColor:
incident.status === "open" ? "#f59e0b20" : "#22c55e20", incident.status === "open" ? "#f59e0b20" : "#88D65620",
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 4, paddingVertical: 4,
@@ -188,7 +223,7 @@ export default function IncidentDetailScreen() {
<Pressable <Pressable
onPress={handleResolve} onPress={handleResolve}
style={{ style={{
backgroundColor: "#22c55e20", backgroundColor: "#88D65620",
borderWidth: 1, borderWidth: 1,
borderColor: Colors.success, borderColor: Colors.success,
borderRadius: 10, borderRadius: 10,
@@ -219,7 +254,7 @@ export default function IncidentDetailScreen() {
</Pressable> </Pressable>
<Pressable <Pressable
onPress={handleGiteaPush} onPress={handleGitPush}
disabled={pushing} disabled={pushing}
style={{ style={{
backgroundColor: pushing ? Colors.surface2 : Colors.surface, backgroundColor: pushing ? Colors.surface2 : Colors.surface,
@@ -237,7 +272,7 @@ export default function IncidentDetailScreen() {
fontSize: 15, fontSize: 15,
}} }}
> >
{pushing ? "Pushing…" : "Push to Gitea"} {pushing ? "Pushing…" : "Push to Git"}
</Text> </Text>
</Pressable> </Pressable>
+8 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
View, View,
Text, Text,
@@ -6,7 +6,7 @@ import {
Pressable, Pressable,
ActivityIndicator, ActivityIndicator,
} from "react-native"; } from "react-native";
import { router } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { getIncidents } from "@/lib/db"; import { getIncidents } from "@/lib/db";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
@@ -26,9 +26,11 @@ export default function HomeScreen() {
} }
}, [ready, settings.homeView]); }, [ready, settings.homeView]);
useEffect(() => { useFocusEffect(
loadIncidents(); useCallback(() => {
}, []); loadIncidents();
}, [])
);
async function loadIncidents() { async function loadIncidents() {
setLoading(true); setLoading(true);
@@ -98,7 +100,7 @@ export default function HomeScreen() {
backgroundColor: backgroundColor:
item.status === "open" item.status === "open"
? "#f59e0b20" ? "#f59e0b20"
: "#22c55e20", : "#88D65620",
borderRadius: 4, borderRadius: 4,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 2, paddingVertical: 2,
+2 -2
View File
@@ -46,7 +46,7 @@ const LABEL_STYLE = {
letterSpacing: 0.5, letterSpacing: 0.5,
} as const; } as const;
type VoiceField = "title" | "symptom" | "rootCause" | "fix"; type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
export default function NewIncidentScreen() { export default function NewIncidentScreen() {
const { editId } = useLocalSearchParams<{ editId?: string }>(); const { editId } = useLocalSearchParams<{ editId?: string }>();
@@ -224,7 +224,7 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<Text style={[LABEL_STYLE, { marginBottom: 6 }]}>Service</Text> {labelRow("Service", "service")}
<TextInput <TextInput
style={FIELD_STYLE} style={FIELD_STYLE}
value={form.service} value={form.service}
+222 -68
View File
@@ -6,22 +6,33 @@ import {
ScrollView, ScrollView,
Switch, Switch,
Pressable, Pressable,
Alert,
} from "react-native"; } from "react-native";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown"; import { DEFAULT_TEMPLATE } from "@/lib/markdown";
import type { GitProvider } from "@/lib/git";
const KEYS = { const KEYS = {
INPUT_MODE: "pref_input_mode", INPUT_MODE: "pref_input_mode",
HOME_VIEW: "pref_home_view", HOME_VIEW: "pref_home_view",
AI_ENABLED: "pref_ai_enabled", AI_ENABLED: "pref_ai_enabled",
AI_KEY: "pref_ai_key", AI_KEY: "pref_ai_key",
MD_TEMPLATE: "md_template",
GIT_PROVIDER: "git_provider",
// Gitea
GITEA_URL: "gitea_url", GITEA_URL: "gitea_url",
GITEA_TOKEN: "gitea_token", GITEA_TOKEN: "gitea_token",
GITEA_OWNER: "gitea_owner", GITEA_OWNER: "gitea_owner",
GITEA_REPO: "gitea_repo", GITEA_REPO: "gitea_repo",
MD_TEMPLATE: "md_template", // GitHub
GITHUB_TOKEN: "github_token",
GITHUB_OWNER: "github_owner",
GITHUB_REPO: "github_repo",
// GitLab
GITLAB_URL: "gitlab_url",
GITLAB_TOKEN: "gitlab_token",
GITLAB_OWNER: "gitlab_owner",
GITLAB_REPO: "gitlab_repo",
} as const; } as const;
const LABEL = { const LABEL = {
@@ -53,40 +64,76 @@ const INPUT = {
fontFamily: "monospace", fontFamily: "monospace",
} as const; } as const;
const PROVIDER_LABELS: Record<GitProvider, string> = {
gitea: "Gitea",
github: "GitHub",
gitlab: "GitLab",
};
export default function SettingsScreen() { export default function SettingsScreen() {
const [inputMode, setInputMode] = useState<"voice" | "form">("form"); const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard"); const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false); const [aiEnabled, setAiEnabled] = useState(false);
const [aiKey, setAiKey] = useState(""); const [aiKey, setAiKey] = useState("");
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
const [saved, setSaved] = useState(false);
// Git provider
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
// Gitea
const [giteaUrl, setGiteaUrl] = useState(""); const [giteaUrl, setGiteaUrl] = useState("");
const [giteaToken, setGiteaToken] = useState(""); const [giteaToken, setGiteaToken] = useState("");
const [giteaOwner, setGiteaOwner] = useState(""); const [giteaOwner, setGiteaOwner] = useState("");
const [giteaRepo, setGiteaRepo] = useState(""); const [giteaRepo, setGiteaRepo] = useState("");
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE); // GitHub
const [saved, setSaved] = useState(false); const [githubToken, setGithubToken] = useState("");
const [githubOwner, setGithubOwner] = useState("");
const [githubRepo, setGithubRepo] = useState("");
// GitLab
const [gitlabUrl, setGitlabUrl] = useState("");
const [gitlabToken, setGitlabToken] = useState("");
const [gitlabOwner, setGitlabOwner] = useState("");
const [gitlabRepo, setGitlabRepo] = useState("");
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const [im, hv, ai, key, gu, gt, go, gr, tpl] = await Promise.all([ const [im, hv, ai, key, tpl, gp, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] =
SecureStore.getItemAsync(KEYS.INPUT_MODE), await Promise.all([
SecureStore.getItemAsync(KEYS.HOME_VIEW), SecureStore.getItemAsync(KEYS.INPUT_MODE),
SecureStore.getItemAsync(KEYS.AI_ENABLED), SecureStore.getItemAsync(KEYS.HOME_VIEW),
SecureStore.getItemAsync(KEYS.AI_KEY), SecureStore.getItemAsync(KEYS.AI_ENABLED),
SecureStore.getItemAsync(KEYS.GITEA_URL), SecureStore.getItemAsync(KEYS.AI_KEY),
SecureStore.getItemAsync(KEYS.GITEA_TOKEN), SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
SecureStore.getItemAsync(KEYS.GITEA_OWNER), SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
SecureStore.getItemAsync(KEYS.GITEA_REPO), SecureStore.getItemAsync(KEYS.GITEA_URL),
SecureStore.getItemAsync(KEYS.MD_TEMPLATE), SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
]); SecureStore.getItemAsync(KEYS.GITEA_OWNER),
SecureStore.getItemAsync(KEYS.GITEA_REPO),
SecureStore.getItemAsync(KEYS.GITHUB_TOKEN),
SecureStore.getItemAsync(KEYS.GITHUB_OWNER),
SecureStore.getItemAsync(KEYS.GITHUB_REPO),
SecureStore.getItemAsync(KEYS.GITLAB_URL),
SecureStore.getItemAsync(KEYS.GITLAB_TOKEN),
SecureStore.getItemAsync(KEYS.GITLAB_OWNER),
SecureStore.getItemAsync(KEYS.GITLAB_REPO),
]);
if (im) setInputMode(im as "voice" | "form"); if (im) setInputMode(im as "voice" | "form");
if (hv) setHomeView(hv as "dashboard" | "capture"); if (hv) setHomeView(hv as "dashboard" | "capture");
if (ai) setAiEnabled(ai === "true"); if (ai) setAiEnabled(ai === "true");
if (key) setAiKey(key); if (key) setAiKey(key);
if (tpl) setMdTemplate(tpl);
if (gp) setGitProvider(gp as GitProvider);
if (gu) setGiteaUrl(gu); if (gu) setGiteaUrl(gu);
if (gt) setGiteaToken(gt); if (gt) setGiteaToken(gt);
if (go) setGiteaOwner(go); if (go) setGiteaOwner(go);
if (gr) setGiteaRepo(gr); if (gr) setGiteaRepo(gr);
if (tpl) setMdTemplate(tpl); if (ghu) setGithubToken(ghu);
if (ghown) setGithubOwner(ghown);
if (ghrepo) setGithubRepo(ghrepo);
if (glu) setGitlabUrl(glu);
if (glt) setGitlabToken(glt);
if (glo) setGitlabOwner(glo);
if (glr) setGitlabRepo(glr);
})(); })();
}, []); }, []);
@@ -96,11 +143,19 @@ export default function SettingsScreen() {
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView), SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)), SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey), SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl), SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken), SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner), SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner),
SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo), SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate), SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken),
SecureStore.setItemAsync(KEYS.GITHUB_OWNER, githubOwner),
SecureStore.setItemAsync(KEYS.GITHUB_REPO, githubRepo),
SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlabUrl),
SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken),
SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlabOwner),
SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlabRepo),
]); ]);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
@@ -171,8 +226,8 @@ export default function SettingsScreen() {
> >
<Text <Text
style={{ style={{
color: value === opt.key ? "#fff" : Colors.text2, color: value === opt.key ? Colors.bg : Colors.text2,
fontWeight: "600", fontWeight: "700",
fontSize: 13, fontSize: 13,
}} }}
> >
@@ -185,6 +240,38 @@ export default function SettingsScreen() {
); );
} }
function Field({
label,
value,
onChange,
placeholder,
secure,
url,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
secure?: boolean;
url?: boolean;
}) {
return (
<>
<Text style={LABEL}>{label}</Text>
<TextInput
style={INPUT}
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor={Colors.textDim}
secureTextEntry={secure}
autoCapitalize="none"
keyboardType={url ? "url" : "default"}
/>
</>
);
}
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
@@ -220,59 +307,126 @@ export default function SettingsScreen() {
onValueChange={setAiEnabled} onValueChange={setAiEnabled}
/> />
{aiEnabled && ( {aiEnabled && (
<Field
label="API Key"
value={aiKey}
onChange={setAiKey}
placeholder="sk-..."
secure
/>
)}
<Text style={SECTION_TITLE}>Git Repository</Text>
<SegmentRow
label="Provider"
value={gitProvider}
onChange={(v) => setGitProvider(v as GitProvider)}
options={(["gitea", "github", "gitlab"] as GitProvider[]).map((p) => ({
key: p,
label: PROVIDER_LABELS[p],
}))}
/>
{gitProvider === "gitea" && (
<> <>
<Text style={LABEL}>API Key</Text> <Field
<TextInput label="Instance URL"
style={INPUT} value={giteaUrl}
value={aiKey} onChange={setGiteaUrl}
onChangeText={setAiKey} placeholder="https://homegit.example.com"
placeholder="sk-..." url
placeholderTextColor={Colors.textDim} />
secureTextEntry <Field
autoCapitalize="none" label="Token"
value={giteaToken}
onChange={setGiteaToken}
placeholder="Bearer token"
secure
/>
<Field
label="Owner"
value={giteaOwner}
onChange={setGiteaOwner}
placeholder="username or org"
/>
<Field
label="Repository"
value={giteaRepo}
onChange={setGiteaRepo}
placeholder="incidents"
/> />
</> </>
)} )}
<Text style={SECTION_TITLE}>Gitea</Text> {gitProvider === "github" && (
<Text style={LABEL}>Instance URL</Text> <>
<TextInput <View
style={INPUT} style={{
value={giteaUrl} backgroundColor: Colors.surface,
onChangeText={setGiteaUrl} borderRadius: 8,
placeholder="https://homegit.gyozamancave.fr" padding: 12,
placeholderTextColor={Colors.textDim} marginBottom: 14,
autoCapitalize="none" borderLeftWidth: 3,
keyboardType="url" borderLeftColor: Colors.primary,
/> }}
<Text style={LABEL}>Token</Text> >
<TextInput <Text style={{ color: Colors.text2, fontSize: 13 }}>
style={INPUT} Uses api.github.com no instance URL required.
value={giteaToken} </Text>
onChangeText={setGiteaToken} </View>
placeholder="Bearer token" <Field
placeholderTextColor={Colors.textDim} label="Personal Access Token"
secureTextEntry value={githubToken}
autoCapitalize="none" onChange={setGithubToken}
/> placeholder="ghp_..."
<Text style={LABEL}>Owner</Text> secure
<TextInput />
style={INPUT} <Field
value={giteaOwner} label="Owner (username or org)"
onChangeText={setGiteaOwner} value={githubOwner}
placeholder="username or org" onChange={setGithubOwner}
placeholderTextColor={Colors.textDim} placeholder="your-username"
autoCapitalize="none" />
/> <Field
<Text style={LABEL}>Repository</Text> label="Repository"
<TextInput value={githubRepo}
style={INPUT} onChange={setGithubRepo}
value={giteaRepo} placeholder="incidents"
onChangeText={setGiteaRepo} />
placeholder="incidents" </>
placeholderTextColor={Colors.textDim} )}
autoCapitalize="none"
/> {gitProvider === "gitlab" && (
<>
<Field
label="Instance URL"
value={gitlabUrl}
onChange={setGitlabUrl}
placeholder="https://gitlab.com"
url
/>
<Field
label="Personal Access Token"
value={gitlabToken}
onChange={setGitlabToken}
placeholder="glpat-..."
secure
/>
<Field
label="Namespace (user or group)"
value={gitlabOwner}
onChange={setGitlabOwner}
placeholder="your-username"
/>
<Field
label="Repository"
value={gitlabRepo}
onChange={setGitlabRepo}
placeholder="incidents"
/>
</>
)}
<Text style={SECTION_TITLE}>Markdown Template</Text> <Text style={SECTION_TITLE}>Markdown Template</Text>
<TextInput <TextInput
@@ -294,7 +448,7 @@ export default function SettingsScreen() {
marginTop: 8, marginTop: 8,
}} }}
> >
<Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}> <Text style={{ color: Colors.bg, fontSize: 16, fontWeight: "700" }}>
{saved ? "Saved ✓" : "Save Settings"} {saved ? "Saved ✓" : "Save Settings"}
</Text> </Text>
</Pressable> </Pressable>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+12 -12
View File
@@ -1,16 +1,16 @@
export const Colors = { export const Colors = {
bg: "#0f1117", bg: "#1B2433",
surface: "#1a1d27", surface: "#222D3D",
surface2: "#252836", surface2: "#293648",
primary: "#6366f1", primary: "#88D656",
primaryDim: "#4338ca", primaryDim: "#5FA33A",
success: "#22c55e", success: "#88D656",
warning: "#f59e0b", warning: "#F5A623",
danger: "#ef4444", danger: "#EF4444",
text1: "#f1f5f9", text1: "#EEF2F5",
text2: "#94a3b8", text2: "#8FA5BF",
textDim: "#64748b", textDim: "#4E6480",
border: "#2d3147", border: "#2E3F55",
} as const; } as const;
export const FontFamily = { export const FontFamily = {
+174
View File
@@ -0,0 +1,174 @@
import { incidentToFilename, renderMarkdown } from "./markdown";
import type { Incident } from "@/types/incident";
export type GitProvider = "gitea" | "github" | "gitlab";
export interface GitConfig {
provider: GitProvider;
url?: string; // required for gitea and self-hosted gitlab
token: string;
owner: string;
repo: string;
}
export interface PushResult {
success: boolean;
url?: string;
error?: string;
}
export async function pushIncidentToGit(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
switch (config.provider) {
case "gitea":
return pushGitea(incident, config, markdownTemplate);
case "github":
return pushGitHub(incident, config, markdownTemplate);
case "gitlab":
return pushGitLab(incident, config, markdownTemplate);
}
}
async function pushGitea(
incident: Incident,
config: GitConfig,
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 {
const existing = await fetch(endpoint, {
headers: { Authorization: `Bearer ${config.token}` },
});
let sha: string | undefined;
if (existing.ok) {
const data = await existing.json() as { sha?: string };
sha = data.sha;
}
const body: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
};
if (sha) body.sha = sha;
const res = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` };
}
const data = await res.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) };
}
}
async function pushGitHub(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
const existing = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json",
},
});
let sha: string | undefined;
if (existing.ok) {
const data = await existing.json() as { sha?: string };
sha = data.sha;
}
const body: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
};
if (sha) body.sha = sha;
const res = await fetch(endpoint, {
method: "PUT",
headers: {
Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` };
}
const data = await res.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) };
}
}
async function pushGitLab(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = (config.url ?? "https://gitlab.com").replace(/\/$/, "");
const projectId = encodeURIComponent(`${config.owner}/${config.repo}`);
const encodedPath = encodeURIComponent(filepath);
const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`;
try {
const existing = await fetch(`${endpoint}?ref=HEAD`, {
headers: { "PRIVATE-TOKEN": config.token },
});
const method = existing.ok ? "PUT" : "POST";
const res = await fetch(endpoint, {
method,
headers: {
"PRIVATE-TOKEN": config.token,
"Content-Type": "application/json",
},
body: JSON.stringify({
branch: "main",
content: base64Content,
encoding: "base64",
commit_message: `incident: ${incident.title}`,
}),
});
if (!res.ok) {
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` };
}
const data = await res.json() as { file_path?: string };
const webUrl = data.file_path
? `${apiBase}/${config.owner}/${config.repo}/-/blob/main/${data.file_path}`
: undefined;
return { success: true, url: webUrl };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
+4 -4
View File
@@ -6748,16 +6748,16 @@
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.7", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.2.7" "react": "^19.2.3"
} }
}, },
"node_modules/react-fast-compare": { "node_modules/react-fast-compare": {
+4
View File
@@ -27,6 +27,10 @@
"react-native-screens": "~4.25.2", "react-native-screens": "~4.25.2",
"tailwindcss": "~3.4.19" "tailwindcss": "~3.4.19"
}, },
"overrides": {
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.0", "@babel/core": "^7.25.0",
"@expo/metro-config": "~56.0.14", "@expo/metro-config": "~56.0.14",