feat(task): undo toast for completing — mis-taps are one tap to take back
completeTask returns an undo closure that restores status + sort slot and trashes the occurrence a repeat spawned. One global snackbar (5s, replace-on-new) mounted in the root layout; every checkbox and Clarify's save-as-done go through completeWithUndo. bun tests for the store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
This commit is contained in:
98
app/src/components/Toast.tsx
Normal file
98
app/src/components/Toast.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
// One app-wide snackbar, mounted once in the root layout. `showToast` is a
|
||||
// plain function so stores and rows can call it without a context hop. A new
|
||||
// toast replaces the current one (last action wins) and the timer restarts.
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text } from "react-native";
|
||||
import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { haptic } from "../lib/haptics";
|
||||
import { C, radius, shadow, space, type } from "../lib/theme";
|
||||
|
||||
export interface ToastSpec {
|
||||
message: string;
|
||||
action?: { label: string; onPress: () => void };
|
||||
}
|
||||
|
||||
const DURATION_MS = 5000;
|
||||
// Sits on the home FAB's baseline (index.tsx `fab.bottom`) — above the
|
||||
// launcher bar, and the right padding leaves the FAB itself uncovered.
|
||||
const FAB_BOTTOM = 104;
|
||||
const FAB_CLEARANCE = space.xl + 56 + space.md;
|
||||
|
||||
let setCurrent: ((t: ToastSpec | null) => void) | null = null;
|
||||
|
||||
export function showToast(spec: ToastSpec) {
|
||||
setCurrent?.(spec);
|
||||
}
|
||||
|
||||
export function ToastHost() {
|
||||
const [toast, setToast] = useState<ToastSpec | null>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrent = setToast;
|
||||
return () => {
|
||||
if (setCurrent === setToast) setCurrent = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timer = setTimeout(() => setToast(null), DURATION_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast]);
|
||||
|
||||
if (!toast) return null;
|
||||
const { action } = toast;
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(180)}
|
||||
exiting={FadeOutDown.duration(150)}
|
||||
style={[s.wrap, { bottom: FAB_BOTTOM + insets.bottom }]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<Pressable style={s.toast} onPress={() => setToast(null)}>
|
||||
<Text style={s.message} numberOfLines={1}>
|
||||
{toast.message}
|
||||
</Text>
|
||||
{action && (
|
||||
<Pressable
|
||||
hitSlop={12}
|
||||
onPress={() => {
|
||||
haptic.tap();
|
||||
setToast(null);
|
||||
action.onPress();
|
||||
}}
|
||||
style={({ pressed }) => [s.action, pressed && { opacity: 0.6 }]}
|
||||
>
|
||||
<Text style={s.actionText}>{action.label}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: {
|
||||
position: "absolute",
|
||||
left: space.lg,
|
||||
right: FAB_CLEARANCE,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
toast: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: space.lg,
|
||||
maxWidth: 420,
|
||||
paddingVertical: space.md,
|
||||
paddingLeft: space.lg,
|
||||
paddingRight: space.md,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: C.text,
|
||||
...shadow.fab,
|
||||
},
|
||||
message: { ...type.row, color: C.bg, flexShrink: 1 },
|
||||
action: { paddingVertical: space.xs, paddingHorizontal: space.sm },
|
||||
actionText: { ...type.row, color: C.accentSoft, fontWeight: "600" },
|
||||
});
|
||||
Reference in New Issue
Block a user