Files
billisdead 9f163d3b76
Release APK / build (push) Has been cancelled
feat: safe area fix, delete from repo, add Mistral provider
- Wrap root layout with SafeAreaProvider, use useSafeAreaInsets in all
  screens so FAB/action bar/scroll content clear Android nav bar
- Add deleteIncidentFromGit() (Gitea/GitHub/GitLab) — detail screen now
  offers Cancel / Local only / Local + Repo when incident was pushed
- Add Mistral as AI provider (OpenAI-compat, api.mistral.ai/v1,
  mistral-small-latest default)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 09:54:02 +02:00

549 lines
17 KiB
TypeScript

import { useState, useEffect } from "react";
import {
View,
Text,
TextInput,
ScrollView,
Switch,
Pressable,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown";
import type { GitProvider } from "@/lib/git";
import type { AIProvider } from "@/lib/ai";
import { AI_PROVIDER_BASE_URLS, AI_PROVIDER_MODEL_PLACEHOLDERS } from "@/lib/ai";
const KEYS = {
INPUT_MODE: "pref_input_mode",
HOME_VIEW: "pref_home_view",
AI_ENABLED: "pref_ai_enabled",
AI_PROVIDER: "pref_ai_provider",
AI_KEY: "pref_ai_key",
AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model",
TITLE_TEMPLATE: "pref_title_template",
MD_TEMPLATE: "md_template",
GIT_PROVIDER: "git_provider",
GITEA_URL: "gitea_url",
GITEA_TOKEN: "gitea_token",
GITEA_OWNER: "gitea_owner",
GITEA_REPO: "gitea_repo",
GITHUB_TOKEN: "github_token",
GITHUB_OWNER: "github_owner",
GITHUB_REPO: "github_repo",
GITLAB_URL: "gitlab_url",
GITLAB_TOKEN: "gitlab_token",
GITLAB_OWNER: "gitlab_owner",
GITLAB_REPO: "gitlab_repo",
} 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;
const PROVIDER_LABELS: Record<GitProvider, string> = {
gitea: "Gitea",
github: "GitHub",
gitlab: "GitLab",
};
function parseRepoUrl(raw: string, provider: GitProvider): { instanceUrl?: string; owner?: string; repo?: string } {
try {
const cleaned = raw.trim().replace(/\.git$/, "");
const u = new URL(cleaned);
const parts = u.pathname.split("/").filter(Boolean);
if (parts.length < 2) return {};
const repo = parts[parts.length - 1];
const owner = parts[parts.length - 2];
const instanceUrl = provider !== "github" ? `${u.protocol}//${u.host}` : undefined;
return { instanceUrl, owner, repo };
} catch {
return {};
}
}
function buildRepoUrl(instanceUrl: string | null, owner: string | null, repo: string | null, provider: GitProvider): string {
if (!owner || !repo) return "";
const base = provider === "github" ? "https://github.com" : (instanceUrl ?? "");
if (!base) return "";
return `${base}/${owner}/${repo}.git`;
}
// Defined outside SettingsScreen — prevents remount on every rerender, which
// would cause TextInput to lose focus after each keystroke.
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,
wrap,
}: {
label: string;
options: { key: string; label: string }[];
value: string;
onChange: (v: string) => void;
wrap?: boolean;
}) {
return (
<View style={{ marginBottom: 16 }}>
<Text style={LABEL}>{label}</Text>
<View style={{ flexDirection: "row", gap: 8, flexWrap: wrap ? "wrap" : "nowrap" }}>
{options.map((opt) => (
<Pressable
key={opt.key}
onPress={() => onChange(opt.key)}
style={{
...(wrap ? { width: "31%" } : { 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 ? Colors.bg : Colors.text2,
fontWeight: "700",
fontSize: 13,
}}
>
{opt.label}
</Text>
</Pressable>
))}
</View>
</View>
);
}
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"
autoCorrect={false}
keyboardType={url ? "url" : "default"}
/>
</>
);
}
export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false);
const [aiProvider, setAiProvider] = useState<AIProvider>("openai");
const [aiKey, setAiKey] = useState("");
const [aiBaseUrl, setAiBaseUrl] = useState("");
const [aiModel, setAiModel] = useState("");
const [titleTemplate, setTitleTemplate] = useState("");
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
const [saved, setSaved] = useState(false);
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
const [giteaRepoUrl, setGiteaRepoUrl] = useState("");
const [giteaToken, setGiteaToken] = useState("");
const [githubRepoUrl, setGithubRepoUrl] = useState("");
const [githubToken, setGithubToken] = useState("");
const [gitlabRepoUrl, setGitlabRepoUrl] = useState("");
const [gitlabToken, setGitlabToken] = useState("");
useEffect(() => {
(async () => {
const [
im, hv, ai, aip, key, aiUrl, aiMdl, titleTpl, tpl,
gp, gu, gt, go, gr,
ghu, ghown, ghrepo,
glu, glt, glo, glr,
] = await Promise.all([
SecureStore.getItemAsync(KEYS.INPUT_MODE),
SecureStore.getItemAsync(KEYS.HOME_VIEW),
SecureStore.getItemAsync(KEYS.AI_ENABLED),
SecureStore.getItemAsync(KEYS.AI_PROVIDER),
SecureStore.getItemAsync(KEYS.AI_KEY),
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL),
SecureStore.getItemAsync(KEYS.TITLE_TEMPLATE),
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
SecureStore.getItemAsync(KEYS.GITEA_URL),
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 (hv) setHomeView(hv as "dashboard" | "capture");
if (ai) setAiEnabled(ai === "true");
if (aip) setAiProvider(aip as AIProvider);
if (key) setAiKey(key);
if (aiUrl) setAiBaseUrl(aiUrl);
if (aiMdl) setAiModel(aiMdl);
if (titleTpl) setTitleTemplate(titleTpl);
if (tpl) setMdTemplate(tpl);
if (gp) setGitProvider(gp as GitProvider);
if (gt) setGiteaToken(gt);
setGiteaRepoUrl(buildRepoUrl(gu, go, gr, "gitea"));
if (ghu) setGithubToken(ghu);
setGithubRepoUrl(buildRepoUrl(null, ghown, ghrepo, "github"));
if (glt) setGitlabToken(glt);
setGitlabRepoUrl(buildRepoUrl(glu, glo, glr, "gitlab"));
})();
}, []);
async function handleSave() {
const gitea = parseRepoUrl(giteaRepoUrl, "gitea");
const github = parseRepoUrl(githubRepoUrl, "github");
const gitlab = parseRepoUrl(gitlabRepoUrl, "gitlab");
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_PROVIDER, aiProvider),
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl),
SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
SecureStore.setItemAsync(KEYS.GITEA_URL, gitea.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, gitea.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_REPO, gitea.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken),
SecureStore.setItemAsync(KEYS.GITHUB_OWNER, github.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_REPO, github.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlab.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken),
SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlab.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlab.repo ?? ""),
]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
return (
<ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 48 + insets.bottom }}
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" },
]}
/>
<Field
label="Title Template"
value={titleTemplate}
onChange={setTitleTemplate}
placeholder="INC-001 (leave empty to disable)"
/>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 10,
marginTop: -8,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
Pre-fills the Title field on new incidents. If the value ends with
digits, the number is auto-incremented after each save.{"\n"}
e.g. INC-001 INC-002 INC-003
</Text>
</View>
<Text style={SECTION_TITLE}>AI Root Cause Assistant</Text>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 12,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}>
When enabled, a Suggest button appears between Symptom and Root Cause.
It sends the title and symptom to your AI provider and pre-fills the
analysis fields. Supports Anthropic natively; all others use the
OpenAI-compatible API.
</Text>
</View>
<ToggleRow
label="Enable AI assistance"
value={aiEnabled}
onValueChange={setAiEnabled}
/>
{aiEnabled && (
<>
<SegmentRow
label="Provider"
value={aiProvider}
wrap
onChange={(v) => {
const p = v as AIProvider;
setAiProvider(p);
setAiBaseUrl(AI_PROVIDER_BASE_URLS[p]);
}}
options={[
{ key: "anthropic", label: "Anthropic" },
{ key: "openai", label: "OpenAI" },
{ key: "mistral", label: "Mistral" },
{ key: "perplexity", label: "Perplexity" },
{ key: "ollama", label: "Ollama" },
{ key: "openrouter", label: "OpenRouter" },
{ key: "custom", label: "Custom" },
]}
/>
{aiProvider !== "anthropic" && (
<Field
label="Base URL"
value={aiBaseUrl}
onChange={setAiBaseUrl}
placeholder={AI_PROVIDER_BASE_URLS[aiProvider] || "https://your-api.example.com/v1"}
url
/>
)}
<Field
label="Model"
value={aiModel}
onChange={setAiModel}
placeholder={AI_PROVIDER_MODEL_PLACEHOLDERS[aiProvider]}
/>
<Field
label="API Key"
value={aiKey}
onChange={setAiKey}
placeholder={aiProvider === "ollama" ? "no key required" : "paste your API key"}
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" && (
<>
<Field
label="Repo URL"
value={giteaRepoUrl}
onChange={setGiteaRepoUrl}
placeholder="https://homegit.example.com/username/incidents.git"
url
/>
<Field
label="Token (PAT, write:repository)"
value={giteaToken}
onChange={setGiteaToken}
placeholder="paste token value — no Bearer prefix"
secure
/>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 10,
marginTop: -8,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
The repo must have at least one commit initialize it with a README if empty.
</Text>
</View>
</>
)}
{gitProvider === "github" && (
<>
<Field
label="Repo URL"
value={githubRepoUrl}
onChange={setGithubRepoUrl}
placeholder="https://github.com/username/incidents.git"
url
/>
<Field
label="Personal Access Token"
value={githubToken}
onChange={setGithubToken}
placeholder="ghp_..."
secure
/>
</>
)}
{gitProvider === "gitlab" && (
<>
<Field
label="Repo URL"
value={gitlabRepoUrl}
onChange={setGitlabRepoUrl}
placeholder="https://gitlab.com/username/incidents.git"
url
/>
<Field
label="Personal Access Token"
value={gitlabToken}
onChange={setGitlabToken}
placeholder="glpat-..."
secure
/>
</>
)}
<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: Colors.bg, fontSize: 16, fontWeight: "700" }}>
{saved ? "Saved ✓" : "Save Settings"}
</Text>
</Pressable>
</ScrollView>
);
}