9f163d3b76
Release APK / build (push) Has been cancelled
- 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>
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
Alert,
|
|
} from "react-native";
|
|
import { router, useFocusEffect } from "expo-router";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import * as SecureStore from "expo-secure-store";
|
|
import { getIncidents, markIncidentPushed } from "@/lib/db";
|
|
import { pushIncidentToGit, loadGitConfig } from "@/lib/git";
|
|
import type { GitConfig } from "@/lib/git";
|
|
import { useSettings } from "@/hooks/useSettings";
|
|
import type { Incident } from "@/types/incident";
|
|
import { Colors } from "@/constants/theme";
|
|
|
|
function gitDotColor(inc: Incident): string {
|
|
if (!inc.gitPushedAt) return Colors.danger;
|
|
if (inc.gitPushedAt < inc.updatedAt) return Colors.warning;
|
|
return Colors.success;
|
|
}
|
|
|
|
export default function HomeScreen() {
|
|
const [incidents, setIncidents] = useState<Incident[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [gitConfig, setGitConfig] = useState<GitConfig | null>(null);
|
|
const [mdTemplate, setMdTemplate] = useState<string | undefined>(undefined);
|
|
const [selectionMode, setSelectionMode] = useState(false);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [bulkPushing, setBulkPushing] = useState(false);
|
|
const [bulkProgress, setBulkProgress] = useState({ done: 0, total: 0 });
|
|
const { settings, ready } = useSettings();
|
|
const didRedirect = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!ready || didRedirect.current) return;
|
|
if (settings.homeView === "capture") {
|
|
didRedirect.current = true;
|
|
router.push("/new");
|
|
}
|
|
}, [ready, settings.homeView]);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
load();
|
|
}, [])
|
|
);
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
const [data, config, tpl] = await Promise.all([
|
|
getIncidents(100),
|
|
loadGitConfig(),
|
|
SecureStore.getItemAsync("md_template"),
|
|
]);
|
|
setIncidents(data);
|
|
setGitConfig(config);
|
|
setMdTemplate(tpl ?? undefined);
|
|
setLoading(false);
|
|
}
|
|
|
|
function enterSelectionMode(id: string) {
|
|
setSelectionMode(true);
|
|
setSelected(new Set([id]));
|
|
}
|
|
|
|
function toggleSelection(id: string) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function exitSelectionMode() {
|
|
setSelectionMode(false);
|
|
setSelected(new Set());
|
|
}
|
|
|
|
function selectAllUnpushed() {
|
|
const ids = incidents
|
|
.filter((i) => !i.gitPushedAt || i.gitPushedAt < i.updatedAt)
|
|
.map((i) => i.id);
|
|
setSelected(new Set(ids));
|
|
}
|
|
|
|
const unpushedCount = incidents.filter(
|
|
(i) => !i.gitPushedAt || i.gitPushedAt < i.updatedAt
|
|
).length;
|
|
|
|
async function handleBulkPush() {
|
|
if (!gitConfig) {
|
|
Alert.alert("Git not configured", "Set up a git provider in Settings first.");
|
|
return;
|
|
}
|
|
const toPush = incidents.filter((i) => selected.has(i.id));
|
|
if (toPush.length === 0) return;
|
|
|
|
setBulkPushing(true);
|
|
setBulkProgress({ done: 0, total: toPush.length });
|
|
|
|
let ok = 0;
|
|
let fail = 0;
|
|
const now = new Date().toISOString();
|
|
|
|
for (let i = 0; i < toPush.length; i++) {
|
|
const inc = toPush[i];
|
|
const result = await pushIncidentToGit(inc, gitConfig, mdTemplate);
|
|
if (result.success) {
|
|
await markIncidentPushed(inc.id);
|
|
setIncidents((prev) =>
|
|
prev.map((x) => (x.id === inc.id ? { ...x, gitPushedAt: now } : x))
|
|
);
|
|
ok++;
|
|
} else {
|
|
fail++;
|
|
}
|
|
setBulkProgress({ done: i + 1, total: toPush.length });
|
|
}
|
|
|
|
setBulkPushing(false);
|
|
exitSelectionMode();
|
|
|
|
if (fail === 0) {
|
|
Alert.alert("Done", `${ok} incident${ok > 1 ? "s" : ""} pushed.`);
|
|
} else {
|
|
Alert.alert("Partial", `${ok} pushed, ${fail} failed.`);
|
|
}
|
|
}
|
|
|
|
const gitConfigured = gitConfig !== null;
|
|
const insets = useSafeAreaInsets();
|
|
|
|
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 + insets.bottom }}
|
|
ListEmptyComponent={
|
|
<Text
|
|
style={{
|
|
color: Colors.textDim,
|
|
textAlign: "center",
|
|
marginTop: 64,
|
|
fontSize: 15,
|
|
}}
|
|
>
|
|
No incidents. Press + to log one.
|
|
</Text>
|
|
}
|
|
renderItem={({ item }) => {
|
|
const isSelected = selected.has(item.id);
|
|
return (
|
|
<Pressable
|
|
onPress={() =>
|
|
selectionMode
|
|
? toggleSelection(item.id)
|
|
: router.push(`/incident/${item.id}`)
|
|
}
|
|
onLongPress={() => !selectionMode && enterSelectionMode(item.id)}
|
|
style={{
|
|
backgroundColor: isSelected ? Colors.surface2 : Colors.surface,
|
|
borderRadius: 8,
|
|
padding: 16,
|
|
marginBottom: 10,
|
|
borderLeftWidth: 3,
|
|
borderLeftColor:
|
|
item.status === "open" ? Colors.warning : Colors.success,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
<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={{ flexDirection: "row", alignItems: "center", marginLeft: 8, gap: 6 }}>
|
|
{gitConfigured && !selectionMode && (
|
|
<View
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: gitDotColor(item),
|
|
}}
|
|
/>
|
|
)}
|
|
<View
|
|
style={{
|
|
backgroundColor:
|
|
item.status === "open" ? "#f59e0b20" : "#88D65620",
|
|
borderRadius: 4,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color:
|
|
item.status === "open"
|
|
? Colors.warning
|
|
: Colors.success,
|
|
fontSize: 11,
|
|
fontWeight: "700",
|
|
textTransform: "uppercase",
|
|
}}
|
|
>
|
|
{item.status}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
<Text
|
|
style={{ color: Colors.text2, fontSize: 12 }}
|
|
numberOfLines={1}
|
|
>
|
|
{item.service || "—"} · {item.createdAt.slice(0, 10)}
|
|
</Text>
|
|
</View>
|
|
|
|
{selectionMode && (
|
|
<View
|
|
style={{
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
borderWidth: 2,
|
|
borderColor: isSelected ? Colors.primary : Colors.border,
|
|
backgroundColor: isSelected ? Colors.primary : "transparent",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
marginLeft: 14,
|
|
}}
|
|
>
|
|
{isSelected && (
|
|
<Text
|
|
style={{ color: Colors.bg, fontSize: 12, fontWeight: "700" }}
|
|
>
|
|
✓
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* FAB — hidden in selection mode */}
|
|
{!selectionMode && (
|
|
<Pressable
|
|
onPress={() => router.push("/new")}
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 24 + insets.bottom,
|
|
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>
|
|
)}
|
|
|
|
{/* Selection mode action bar */}
|
|
{selectionMode && (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
backgroundColor: Colors.surface,
|
|
borderTopWidth: 1,
|
|
borderTopColor: Colors.border,
|
|
padding: 12,
|
|
paddingBottom: 12 + insets.bottom,
|
|
gap: 10,
|
|
}}
|
|
>
|
|
{/* Quick-select shortcuts */}
|
|
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
{gitConfigured && unpushedCount > 0 && (
|
|
<Pressable
|
|
onPress={selectAllUnpushed}
|
|
style={{
|
|
flex: 1,
|
|
borderRadius: 6,
|
|
borderWidth: 1,
|
|
borderColor: Colors.border,
|
|
padding: 8,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Text style={{ color: Colors.text2, fontSize: 12, fontWeight: "600" }}>
|
|
Select unpushed ({unpushedCount})
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
<Pressable
|
|
onPress={() => setSelected(new Set(incidents.map((i) => i.id)))}
|
|
style={{
|
|
flex: 1,
|
|
borderRadius: 6,
|
|
borderWidth: 1,
|
|
borderColor: Colors.border,
|
|
padding: 8,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Text style={{ color: Colors.text2, fontSize: 12, fontWeight: "600" }}>
|
|
Select all ({incidents.length})
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Main actions */}
|
|
<View style={{ flexDirection: "row", gap: 10 }}>
|
|
<Pressable
|
|
onPress={exitSelectionMode}
|
|
style={{
|
|
flex: 1,
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: Colors.border,
|
|
padding: 14,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Text style={{ color: Colors.text2, fontSize: 15, fontWeight: "600" }}>
|
|
Cancel
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={handleBulkPush}
|
|
disabled={selected.size === 0 || bulkPushing || !gitConfigured}
|
|
style={{
|
|
flex: 2,
|
|
borderRadius: 8,
|
|
backgroundColor:
|
|
selected.size === 0 || !gitConfigured
|
|
? Colors.surface2
|
|
: Colors.primary,
|
|
padding: 14,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color:
|
|
selected.size === 0 || !gitConfigured
|
|
? Colors.textDim
|
|
: Colors.bg,
|
|
fontSize: 15,
|
|
fontWeight: "700",
|
|
}}
|
|
>
|
|
{bulkPushing
|
|
? `Pushing ${bulkProgress.done}/${bulkProgress.total}…`
|
|
: `Push ${selected.size > 0 ? selected.size : ""} selected`}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|