Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
174 lines
5.8 KiB
TypeScript
174 lines
5.8 KiB
TypeScript
import type { CardAct, CardSection } from "@rakazo/contracts";
|
|
import { cardTimerRemaining, formatCardClock } from "@rakazo/core";
|
|
import { useKeepAwake } from "expo-keep-awake";
|
|
import { useEffect, useState } from "react";
|
|
import { Modal, Pressable, SafeAreaView, StyleSheet, Text, View } from "react-native";
|
|
import { mobileTokens } from "../lib/appearance";
|
|
import { useI18n } from "../lib/i18n";
|
|
import { NativeSymbol } from "./native-symbol";
|
|
|
|
type Steps = Extract<CardSection, { type: "steps" }>;
|
|
|
|
/** The current time, ticking every second while `active`. */
|
|
export function useNow(active: boolean): number {
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
setNow(Date.now());
|
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => clearInterval(timer);
|
|
}, [active]);
|
|
return now;
|
|
}
|
|
|
|
/**
|
|
* A card's steps one at a time in full screen, large enough to read from the stove, with the
|
|
* screen kept on. The step and any timer live on the card, so the web and a reload agree.
|
|
*/
|
|
export function CookingMode({
|
|
title,
|
|
steps,
|
|
now,
|
|
busy,
|
|
onAct,
|
|
onClose,
|
|
}: {
|
|
title: string;
|
|
steps: Steps;
|
|
now: number;
|
|
busy: boolean;
|
|
onAct: (act: CardAct) => Promise<void>;
|
|
onClose: () => void;
|
|
}) {
|
|
useKeepAwake();
|
|
const tokens = mobileTokens();
|
|
const { t } = useI18n();
|
|
const index = Math.min(steps.current ?? 0, steps.steps.length - 1);
|
|
const step = steps.steps[index];
|
|
const last = index === steps.steps.length - 1;
|
|
const remaining = cardTimerRemaining(step?.timerEndsAt, now);
|
|
const running = remaining !== null && remaining > 0;
|
|
const go = (next: number) => void onAct({ type: "step", sectionId: steps.id, index: next });
|
|
|
|
return (
|
|
<Modal animationType="slide" presentationStyle="fullScreen" onRequestClose={onClose}>
|
|
<SafeAreaView style={[styles.screen, { backgroundColor: tokens.background }]}>
|
|
<View style={styles.top}>
|
|
<Text numberOfLines={1} style={[styles.title, { color: tokens.mutedForeground }]}>
|
|
{title}
|
|
</Text>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("Close")}
|
|
onPress={onClose}
|
|
hitSlop={12}
|
|
>
|
|
<NativeSymbol ios="xmark" android="close" size={20} />
|
|
</Pressable>
|
|
</View>
|
|
<View style={styles.middle}>
|
|
<Text style={[styles.counter, { color: tokens.mutedForeground }]}>
|
|
{t("Step {position} of {total}", {
|
|
position: index + 1,
|
|
total: steps.steps.length,
|
|
})}
|
|
</Text>
|
|
<Text style={[styles.step, { color: tokens.foreground }]}>{step?.text}</Text>
|
|
{step?.timerSeconds ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={running ? t("Stop timer") : t("Start timer")}
|
|
disabled={busy}
|
|
onPress={() =>
|
|
void onAct({
|
|
type: "timer",
|
|
sectionId: steps.id,
|
|
stepId: step.id,
|
|
running: !running,
|
|
})
|
|
}
|
|
style={[
|
|
styles.timer,
|
|
{
|
|
borderColor: running ? tokens.primary : tokens.border,
|
|
backgroundColor: running ? tokens.primary : "transparent",
|
|
},
|
|
]}
|
|
>
|
|
<NativeSymbol
|
|
ios={running ? "pause.fill" : "play.fill"}
|
|
android={running ? "pause" : "play"}
|
|
size={20}
|
|
color={running ? tokens.primaryForeground : tokens.foreground}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.clock,
|
|
{ color: running ? tokens.primaryForeground : tokens.foreground },
|
|
]}
|
|
>
|
|
{formatCardClock(remaining ?? step.timerSeconds)}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
<View style={styles.bottom}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
disabled={busy || index === 0}
|
|
onPress={() => go(index - 1)}
|
|
style={[styles.nav, { borderColor: tokens.border, opacity: index === 0 ? 0.4 : 1 }]}
|
|
>
|
|
<Text style={[styles.navText, { color: tokens.foreground }]}>{t("Back")}</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
disabled={busy && !last}
|
|
onPress={() => (last ? onClose() : go(index + 1))}
|
|
style={[styles.nav, { borderColor: tokens.primary, backgroundColor: tokens.primary }]}
|
|
>
|
|
<Text style={[styles.navText, { color: tokens.primaryForeground }]}>
|
|
{last ? t("Done") : t("Next")}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</SafeAreaView>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
screen: { flex: 1, paddingHorizontal: 24 },
|
|
top: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 12,
|
|
paddingTop: 16,
|
|
},
|
|
title: { flex: 1, fontSize: 15, fontWeight: "600" },
|
|
middle: { flex: 1, justifyContent: "center", gap: 28 },
|
|
counter: { fontSize: 15 },
|
|
step: { fontSize: 30, lineHeight: 40, fontWeight: "600" },
|
|
timer: {
|
|
alignSelf: "flex-start",
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
borderWidth: 1,
|
|
borderRadius: 16,
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 12,
|
|
},
|
|
clock: { fontSize: 26, fontWeight: "600", fontVariant: ["tabular-nums"] },
|
|
bottom: { flexDirection: "row", justifyContent: "space-between", gap: 12, paddingBottom: 16 },
|
|
nav: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
borderWidth: 1,
|
|
borderRadius: 14,
|
|
paddingVertical: 14,
|
|
},
|
|
navText: { fontSize: 16, fontWeight: "600" },
|
|
});
|