+ {/* ── Top nav ──────────────────────────────────────────────── */}
+
+
+ {/* Placeholder to keep the title centered — the back
+ * chevron would lead to the PIN screen, which the user
+ * got here from. Leaving it as just the title to keep
+ * the nav minimal per HIG. */}
+
+
+
+ {/* ── Digit boxes ────────────────────────────────────── */}
+ {/* The hidden input is the source of truth and gives us
+ * native iOS numeric keyboard + autofill. The visible
+ * boxes mirror its value. */}
+
+
+ {/* ── Unlock button ─────────────────────────────────── */}
+
+
+ {/* Footer hint */}
+
+ Forgot your PIN? Ask the ditch supervisor.
+
+
+
+
+ {/* Apply the shake via inline animation when `shake` is true.
+ * The `water-shake` keyframe lives in `src/app/globals.css`
+ * alongside the rest of the Water Log animation set. */}
+ {shake && (
+
+ )}
+
+ );
+}
+
+export default MobileWaterPinScreen;
\ No newline at end of file
diff --git a/src/components/water/mobile/PullToRefresh.tsx b/src/components/water/mobile/PullToRefresh.tsx
new file mode 100644
index 0000000..d7f4921
--- /dev/null
+++ b/src/components/water/mobile/PullToRefresh.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+/**
+ * MobilePullToRefresh — iOS-style pull-to-refresh for the headgate list.
+ *
+ * Why a custom implementation (vs reusing the admin one):
+ * - The admin PullToRefresh calls `router.refresh()` to re-render
+ * the server component. The mobile flow is fully client-side
+ * (the headgate list comes from a server action), so we need a
+ * callback-based variant that re-fetches via `onRefresh`.
+ * - The mobile version also needs to feel snappier on a phone
+ * (smaller resistance curve, indicator pinned to the top of the
+ * scrollable region).
+ *
+ * The indicator:
+ * - Sits 16px below the top edge of the scroll container.
+ * - Scales + fades in based on pull progress (0 → 1 across the
+ * trigger threshold), with the spinner kicking in only once
+ * refresh actually starts.
+ * - Respects `prefers-reduced-motion` (the rest of the mobile
+ * bundle does too — see `usePrefersReducedMotion` in
+ * `MobileWaterApp`).
+ */
+import {
+ useCallback,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Spinner } from "./icons";
+
+type Props = {
+ onRefresh: () => Promise | void;
+ children: ReactNode;
+ /** Pull distance (px) at which refresh triggers. Default 72. */
+ threshold?: number;
+ /** Maximum stretch distance (px) under heavy pull. Default 120. */
+ maxPull?: number;
+ /** Disable pull-to-refresh (e.g. while loading). */
+ disabled?: boolean;
+};
+
+export function MobilePullToRefresh({
+ onRefresh,
+ children,
+ threshold = 72,
+ maxPull = 120,
+ disabled = false,
+}: Props) {
+ const scrollRef = useRef(null);
+ const startY = useRef(null);
+ const [offset, setOffset] = useState(0);
+ const [refreshing, setRefreshing] = useState(false);
+ const [isPulling, setIsPulling] = useState(false);
+
+ // The pull-to-refresh should only fire when the list is already at
+ // the very top — otherwise the user is mid-scroll and we should
+ // hand the gesture back to the native scroll.
+ const atTop = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return true;
+ return el.scrollTop <= 0;
+ }, []);
+
+ const onTouchStart = useCallback(
+ (e: React.TouchEvent) => {
+ if (disabled || refreshing) return;
+ if (!atTop()) return;
+ startY.current = e.touches[0].clientY;
+ setIsPulling(true);
+ },
+ [disabled, refreshing, atTop],
+ );
+
+ const onTouchMove = useCallback(
+ (e: React.TouchEvent) => {
+ if (!isPulling || startY.current == null) return;
+ const dy = e.touches[0].clientY - startY.current;
+ if (dy <= 0) {
+ setOffset(0);
+ return;
+ }
+ // Resistance curve — feels heavier as you pull further so a
+ // casual swipe doesn't accidentally trigger refresh.
+ const resistance = 1 - Math.min(1, dy / 600);
+ setOffset(Math.min(maxPull, dy * resistance));
+ },
+ [isPulling, maxPull],
+ );
+
+ const onTouchEnd = useCallback(async () => {
+ if (!isPulling) return;
+ setIsPulling(false);
+ startY.current = null;
+ if (offset >= threshold) {
+ setRefreshing(true);
+ setOffset(threshold); // hold the indicator in place while refreshing
+ try {
+ await Promise.resolve(onRefresh());
+ } finally {
+ // A small delay so the spinner is visible even on a fast
+ // refresh — feels less jarring.
+ await new Promise((r) => setTimeout(r, 350));
+ setRefreshing(false);
+ setOffset(0);
+ }
+ } else {
+ setOffset(0);
+ }
+ }, [isPulling, offset, threshold, onRefresh]);
+
+ const progress = Math.min(1, offset / threshold);
+
+ return (
+
+ {/* Indicator — absolutely positioned just below the top edge.
+ * Translates with the pull so it follows the finger. */}
+
+
+ {refreshing ? (
+
+ ) : (
+
+ ↓
+
+ )}
+
+
+ {children}
+
+ );
+}
+
+export default MobilePullToRefresh;
\ No newline at end of file
diff --git a/src/components/water/mobile/SegmentedControl.tsx b/src/components/water/mobile/SegmentedControl.tsx
new file mode 100644
index 0000000..4728a53
--- /dev/null
+++ b/src/components/water/mobile/SegmentedControl.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+/**
+ * MobileWaterSegmented — an iOS-style segmented control.
+ *
+ * Mimics UISegmentedControl:
+ * - Rounded 9px outer container in surface-2 (iOS systemGray5).
+ * - White sliding pill behind the selected option.
+ * - iOS spring timing (cubic-bezier(0.32, 0.72, 0, 1)) for the
+ * pill transition.
+ * - Hugs the largest option's natural width when the parent gives
+ * no fixed width — looks balanced for 3-5 short labels.
+ * - 44pt minimum touch target per HIG.
+ *
+ * Uses a useLayoutEffect ref-measurement pass so the indicator
+ * slides smoothly to the correct option when the value changes
+ * (including the initial mount and across option-list changes).
+ */
+import {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+
+export type SegmentedOption = {
+ value: V;
+ label: ReactNode;
+ /** Optional badge or icon, rendered inside the segment. */
+ badge?: ReactNode;
+};
+
+type Props = {
+ options: ReadonlyArray>;
+ value: V;
+ onChange: (value: V) => void;
+ /** ARIA label for the group. */
+ ariaLabel?: string;
+ /** Optional fixed height (default 44 — the HIG minimum). */
+ height?: number;
+ /** Disable the whole control. */
+ disabled?: boolean;
+};
+
+export function SegmentedControl({
+ options,
+ value,
+ onChange,
+ ariaLabel,
+ height = 44,
+ disabled = false,
+}: Props) {
+ const containerRef = useRef(null);
+ // Cache {x, width} per option key so the sliding indicator animates
+ // even when options aren't measured synchronously (e.g. SSR).
+ const [rects, setRects] = useState>({});
+
+ // Measure on mount and whenever the option set changes.
+ useLayoutEffect(() => {
+ if (!containerRef.current) return;
+ const root = containerRef.current;
+ const next: Record = {};
+ options.forEach((opt) => {
+ const el = root.querySelector(
+ `[data-seg-key="${opt.value}"]`,
+ );
+ if (el) {
+ const rootRect = root.getBoundingClientRect();
+ const elRect = el.getBoundingClientRect();
+ next[opt.value] = { x: elRect.left - rootRect.left, w: elRect.width };
+ }
+ });
+ setRects(next);
+ }, [options]);
+
+ // Re-measure on resize so the indicator stays aligned.
+ useEffect(() => {
+ if (!containerRef.current) return;
+ const ro = new ResizeObserver(() => {
+ const root = containerRef.current!;
+ const next: Record = {};
+ options.forEach((opt) => {
+ const el = root.querySelector(
+ `[data-seg-key="${opt.value}"]`,
+ );
+ if (el) {
+ const rootRect = root.getBoundingClientRect();
+ const elRect = el.getBoundingClientRect();
+ next[opt.value] = { x: elRect.left - rootRect.left, w: elRect.width };
+ }
+ });
+ setRects(next);
+ });
+ ro.observe(containerRef.current);
+ return () => ro.disconnect();
+ }, [options]);
+
+ const active = rects[value];
+
+ return (
+