Files
SheetHappens/app/settings.tsx
T
billisdead 7e42d3a26f
Release APK / build (push) Has been cancelled
feat: add incident delete and clarify AI assistant settings
Wire deleteIncident() into IncidentDetailScreen with a destructive confirm
dialog. Expand the AI settings section with base URL + model fields and an
explanatory card documenting the OpenAI-compatible use case (Ollama / OpenAI).

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

500 lines
14 KiB
TypeScript

import { useState, useEffect } from "react";
import {
View,
Text,
TextInput,
ScrollView,
Switch,
Pressable,
} from "react-native";
import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown";
import type { GitProvider } from "@/lib/git";
const KEYS = {
INPUT_MODE: "pref_input_mode",
HOME_VIEW: "pref_home_view",
AI_ENABLED: "pref_ai_enabled",
AI_KEY: "pref_ai_key",
AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model",
MD_TEMPLATE: "md_template",
GIT_PROVIDER: "git_provider",
// Gitea
GITEA_URL: "gitea_url",
GITEA_TOKEN: "gitea_token",
GITEA_OWNER: "gitea_owner",
GITEA_REPO: "gitea_repo",
// 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;
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",
};
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 [aiBaseUrl, setAiBaseUrl] = useState("");
const [aiModel, setAiModel] = 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 [giteaToken, setGiteaToken] = useState("");
const [giteaOwner, setGiteaOwner] = useState("");
const [giteaRepo, setGiteaRepo] = useState("");
// GitHub
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(() => {
(async () => {
const [im, hv, ai, key, aiUrl, aiMdl, 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_KEY),
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL),
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 (key) setAiKey(key);
if (aiUrl) setAiBaseUrl(aiUrl);
if (aiMdl) setAiModel(aiMdl);
if (tpl) setMdTemplate(tpl);
if (gp) setGitProvider(gp as GitProvider);
if (gu) setGiteaUrl(gu);
if (gt) setGiteaToken(gt);
if (go) setGiteaOwner(go);
if (gr) setGiteaRepo(gr);
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);
})();
}, []);
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.AI_BASE_URL, aiBaseUrl),
SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
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.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);
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 ? 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"
keyboardType={url ? "url" : "default"}
/>
</>
);
}
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 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 button appears on the capture form to suggest a root cause and fix
based on the incident title and symptom. Uses any OpenAI-compatible API
set the base URL below to point at a local Ollama instance or leave empty
for OpenAI (api.openai.com).
</Text>
</View>
<ToggleRow
label="Enable AI assistance"
value={aiEnabled}
onValueChange={setAiEnabled}
/>
{aiEnabled && (
<>
<Field
label="API Base URL"
value={aiBaseUrl}
onChange={setAiBaseUrl}
placeholder="http://192.168.x.x:11434/v1 (empty = OpenAI)"
url
/>
<Field
label="Model"
value={aiModel}
onChange={setAiModel}
placeholder="gpt-4o-mini or llama3.2"
/>
<Field
label="API Key"
value={aiKey}
onChange={setAiKey}
placeholder="sk-... (leave empty for Ollama)"
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="Instance URL"
value={giteaUrl}
onChange={setGiteaUrl}
placeholder="https://homegit.example.com"
url
/>
<Field
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"
/>
</>
)}
{gitProvider === "github" && (
<>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 12,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.primary,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13 }}>
Uses api.github.com no instance URL required.
</Text>
</View>
<Field
label="Personal Access Token"
value={githubToken}
onChange={setGithubToken}
placeholder="ghp_..."
secure
/>
<Field
label="Owner (username or org)"
value={githubOwner}
onChange={setGithubOwner}
placeholder="your-username"
/>
<Field
label="Repository"
value={githubRepo}
onChange={setGithubRepo}
placeholder="incidents"
/>
</>
)}
{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>
<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>
);
}