diff --git a/app.json b/app.json
index 6a0ea7c..7ee6015 100644
--- a/app.json
+++ b/app.json
@@ -5,12 +5,13 @@
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "dark",
- "backgroundColor": "#0f1117",
+ "icon": "./assets/icon.png",
+ "backgroundColor": "#1B2433",
"scheme": "sheethappens",
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
- "backgroundColor": "#0f1117"
+ "backgroundColor": "#1B2433"
},
"package": "fr.gyozamancave.sheethappens",
"permissions": [
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 2e56cf7..dedeab5 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,4 +1,5 @@
import { useEffect } from "react";
+import { Pressable, Text } from "react-native";
import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -28,7 +29,20 @@ export default function RootLayout() {
animation: "slide_from_right",
}}
>
-
+ (
+ router.push("/settings")}
+ style={{ paddingLeft: 12, paddingRight: 4, paddingVertical: 6 }}
+ >
+ ⚙
+
+ ),
+ }}
+ />
diff --git a/app/incident/[id].tsx b/app/incident/[id].tsx
index 185bc06..8980e14 100644
--- a/app/incident/[id].tsx
+++ b/app/incident/[id].tsx
@@ -12,7 +12,8 @@ import * as Clipboard from "expo-clipboard";
import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident } from "@/lib/db";
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 { Colors } from "@/constants/theme";
@@ -59,28 +60,62 @@ export default function IncidentDetailScreen() {
setIncident({ ...incident, status: "resolved" });
}
- async function handleGiteaPush() {
+ async function handleGitPush() {
if (!incident) return;
- const [url, token, owner, repo, template] = await Promise.all([
- SecureStore.getItemAsync("gitea_url"),
- SecureStore.getItemAsync("gitea_token"),
- SecureStore.getItemAsync("gitea_owner"),
- SecureStore.getItemAsync("gitea_repo"),
- SecureStore.getItemAsync("md_template"),
- ]);
- if (!url || !token || !owner || !repo) {
- Alert.alert("Gitea not configured", "Set up Gitea in Settings first.");
+ const [provider, template, giteaUrl, giteaToken, giteaOwner, giteaRepo, githubToken, githubOwner, githubRepo, gitlabUrl, gitlabToken, gitlabOwner, gitlabRepo] =
+ await Promise.all([
+ SecureStore.getItemAsync("git_provider"),
+ SecureStore.getItemAsync("md_template"),
+ SecureStore.getItemAsync("gitea_url"),
+ SecureStore.getItemAsync("gitea_token"),
+ SecureStore.getItemAsync("gitea_owner"),
+ SecureStore.getItemAsync("gitea_repo"),
+ 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;
}
+
setPushing(true);
- const result = await pushIncidentToGitea(
+ const result = await pushIncidentToGit(
incident,
- { url, token, owner, repo },
+ { provider: p, url, token, owner, repo },
template ?? undefined
);
setPushing(false);
if (result.success) {
- Alert.alert("Pushed", result.url ? `${result.url}` : "Push successful.");
+ Alert.alert("Pushed", result.url ?? "Push successful.");
} else {
Alert.alert("Push failed", result.error ?? "Unknown error");
}
@@ -117,7 +152,7 @@ export default function IncidentDetailScreen() {
- {pushing ? "Pushing…" : "Push to Gitea"}
+ {pushing ? "Pushing…" : "Push to Git"}
diff --git a/app/index.tsx b/app/index.tsx
index ec85399..3d87170 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -100,7 +100,7 @@ export default function HomeScreen() {
backgroundColor:
item.status === "open"
? "#f59e0b20"
- : "#22c55e20",
+ : "#88D65620",
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
diff --git a/app/settings.tsx b/app/settings.tsx
index 186f6d2..5e06173 100644
--- a/app/settings.tsx
+++ b/app/settings.tsx
@@ -6,22 +6,33 @@ import {
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";
+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",
+ 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",
- 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;
const LABEL = {
@@ -53,40 +64,76 @@ const INPUT = {
fontFamily: "monospace",
} as const;
+const PROVIDER_LABELS: Record = {
+ 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 [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
+ const [saved, setSaved] = useState(false);
+
+ // Git provider
+ const [gitProvider, setGitProvider] = useState("gitea");
+ // Gitea
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);
+ // 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, 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),
- ]);
+ const [im, hv, ai, key, 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.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 (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 (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.AI_ENABLED, String(aiEnabled)),
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_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner),
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);
setTimeout(() => setSaved(false), 2000);
@@ -171,8 +226,8 @@ export default function SettingsScreen() {
>
@@ -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 (
+ <>
+ {label}
+
+ >
+ );
+ }
+
return (
{aiEnabled && (
+
+ )}
+
+ Git Repository
+
+ setGitProvider(v as GitProvider)}
+ options={(["gitea", "github", "gitlab"] as GitProvider[]).map((p) => ({
+ key: p,
+ label: PROVIDER_LABELS[p],
+ }))}
+ />
+
+ {gitProvider === "gitea" && (
<>
- API Key
-
+
+
+
>
)}
- Gitea
- Instance URL
-
- Token
-
- Owner
-
- Repository
-
+ {gitProvider === "github" && (
+ <>
+
+
+ Uses api.github.com — no instance URL required.
+
+
+
+
+
+ >
+ )}
+
+ {gitProvider === "gitlab" && (
+ <>
+
+
+
+
+ >
+ )}
Markdown Template
-
+
{saved ? "Saved ✓" : "Save Settings"}
diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png
index 154ac68..8de4148 100644
Binary files a/assets/adaptive-icon.png and b/assets/adaptive-icon.png differ
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..05c50db
Binary files /dev/null and b/assets/icon.png differ
diff --git a/constants/theme.ts b/constants/theme.ts
index ac80a39..a458332 100644
--- a/constants/theme.ts
+++ b/constants/theme.ts
@@ -1,16 +1,16 @@
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",
+ bg: "#1B2433",
+ surface: "#222D3D",
+ surface2: "#293648",
+ primary: "#88D656",
+ primaryDim: "#5FA33A",
+ success: "#88D656",
+ warning: "#F5A623",
+ danger: "#EF4444",
+ text1: "#EEF2F5",
+ text2: "#8FA5BF",
+ textDim: "#4E6480",
+ border: "#2E3F55",
} as const;
export const FontFamily = {
diff --git a/lib/git.ts b/lib/git.ts
new file mode 100644
index 0000000..24af9b5
--- /dev/null
+++ b/lib/git.ts
@@ -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 {
+ 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 {
+ 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 = {
+ 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 {
+ 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 = {
+ 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 {
+ 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) };
+ }
+}