feat: git push status indicator + multi-select bulk push
Release APK / build (push) Has been cancelled
Release APK / build (push) Has been cancelled
- DB migration v2: git_pushed_at column on incidents - markIncidentPushed(id): stamps the push timestamp - loadGitConfig(): shared helper in lib/git.ts, removes duplication - Home screen: colored dot per incident (red=never pushed, orange=dirty/modified after push, green=up to date); dot visible only when git is configured - Home screen: long-press enters selection mode, tap toggles; action bar with Select unpushed / Select all shortcuts + bulk push with sequential progress counter - [id].tsx: marks incident pushed after successful push, updates local state without reload Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+321
-86
@@ -5,16 +5,32 @@ import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { getIncidents } from "@/lib/db";
|
||||
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);
|
||||
|
||||
@@ -28,24 +44,99 @@ export default function HomeScreen() {
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadIncidents();
|
||||
load();
|
||||
}, [])
|
||||
);
|
||||
|
||||
async function loadIncidents() {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const data = await getIncidents(10);
|
||||
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;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: Colors.bg }}>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
color={Colors.primary}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
<ActivityIndicator color={Colors.primary} style={{ marginTop: 48 }} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={incidents}
|
||||
@@ -63,98 +154,242 @@ export default function HomeScreen() {
|
||||
No incidents. Press + to log one.
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
onPress={() => router.push(`/incident/${item.id}`)}
|
||||
style={{
|
||||
backgroundColor: Colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 10,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor:
|
||||
item.status === "open" ? Colors.warning : Colors.success,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
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",
|
||||
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={{
|
||||
backgroundColor:
|
||||
item.status === "open"
|
||||
? "#f59e0b20"
|
||||
: "#88D65620",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
<View style={{ flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
color:
|
||||
item.status === "open"
|
||||
? Colors.warning
|
||||
: Colors.success,
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
textTransform: "uppercase",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{item.status}
|
||||
<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>
|
||||
</View>
|
||||
<Text
|
||||
style={{ color: Colors.text2, fontSize: 12 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.service || "—"} · {item.createdAt.slice(0, 10)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{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 */}
|
||||
<Pressable
|
||||
onPress={() => router.push("/new")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 24,
|
||||
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 }}
|
||||
{/* FAB — hidden in selection mode */}
|
||||
{!selectionMode && (
|
||||
<Pressable
|
||||
onPress={() => router.push("/new")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: Colors.primary,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
elevation: 6,
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Text>
|
||||
</Pressable>
|
||||
<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,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user