@lotics/ui 3.5.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/AGENTS.md +323 -0
  2. package/examples/app_orders.tsx +405 -0
  3. package/examples/tpl_allocate.tsx +120 -0
  4. package/examples/tpl_approvals.tsx +375 -0
  5. package/examples/tpl_attendance.tsx +355 -0
  6. package/examples/tpl_batch.tsx +234 -0
  7. package/examples/tpl_calendar.tsx +288 -0
  8. package/examples/tpl_callsheet.tsx +481 -0
  9. package/examples/tpl_convert.tsx +490 -0
  10. package/examples/tpl_crm_desk.tsx +541 -0
  11. package/examples/tpl_dashboard.tsx +554 -0
  12. package/examples/tpl_detail.tsx +232 -0
  13. package/examples/tpl_directory.tsx +263 -0
  14. package/examples/tpl_dispatch.tsx +289 -0
  15. package/examples/tpl_dossier.tsx +431 -0
  16. package/examples/tpl_intake.tsx +206 -0
  17. package/examples/tpl_inventory.tsx +299 -0
  18. package/examples/tpl_order.tsx +483 -0
  19. package/examples/tpl_pick.tsx +240 -0
  20. package/examples/tpl_quick.tsx +210 -0
  21. package/examples/tpl_reconcile.tsx +275 -0
  22. package/examples/tpl_record.tsx +301 -0
  23. package/examples/tpl_record_plain.tsx +154 -0
  24. package/examples/tpl_rollup.tsx +300 -0
  25. package/examples/tpl_run.tsx +235 -0
  26. package/examples/tpl_settings.tsx +178 -0
  27. package/examples/tpl_shifts.tsx +421 -0
  28. package/examples/tpl_stock.tsx +387 -0
  29. package/examples/tpl_timeline.tsx +244 -0
  30. package/examples/tpl_tower.tsx +356 -0
  31. package/examples/tpl_wizard.tsx +223 -0
  32. package/package.json +11 -2
  33. package/src/bar_chart.tsx +5 -0
  34. package/src/callout.tsx +50 -17
  35. package/src/combobox.tsx +22 -6
  36. package/src/form_date_picker.tsx +2 -0
  37. package/src/form_picker.tsx +1 -0
  38. package/src/form_switch.tsx +1 -0
  39. package/src/form_text_input.tsx +2 -0
  40. package/src/icon.tsx +2 -0
  41. package/src/icon_button.tsx +5 -2
  42. package/src/inline_date_picker.tsx +110 -0
  43. package/src/inline_edit.tsx +228 -0
  44. package/src/inline_number_input.tsx +70 -0
  45. package/src/inline_select.tsx +91 -0
  46. package/src/inline_text_input.tsx +71 -0
  47. package/src/inline_time_picker.tsx +64 -0
  48. package/src/line_chart.tsx +4 -0
  49. package/src/list_item.tsx +5 -0
  50. package/src/number_input.tsx +12 -1
  51. package/src/page_content.tsx +5 -0
  52. package/src/section_heading.tsx +43 -29
  53. package/src/tag_input.tsx +202 -0
  54. package/src/time_picker.tsx +15 -3
  55. package/src/tooltip.tsx +19 -0
@@ -0,0 +1,240 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors, solid } from "@lotics/ui/colors";
5
+ import { Badge } from "@lotics/ui/badge";
6
+ import { Button } from "@lotics/ui/button";
7
+ import { Card } from "@lotics/ui/card";
8
+ import { ChipGroup } from "@lotics/ui/chip_group";
9
+ import { Counter } from "@lotics/ui/counter";
10
+ import { Divider } from "@lotics/ui/divider";
11
+ import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
12
+ import { ProgressBar } from "@lotics/ui/progress_bar";
13
+ import { ScanField } from "@lotics/ui/scan_field";
14
+ import { StepList, type StepListItem } from "@lotics/ui/step_list";
15
+ import { CompletionState } from "@lotics/ui/completion_state";
16
+
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+ // Template · Guided execution — a warehouse PICK RUN. The work is a SEQUENCE of
19
+ // physical tasks, not a list to browse: the run hands the operator the next bin,
20
+ // they scan to verify they're at the right shelf, confirm the count, and the
21
+ // next task takes its place. Two panes use the screen the way the work wants —
22
+ // the CURRENT task large on the left (where to walk, what to take, how many), the
23
+ // whole PICK PATH on the right (done · now · up next) so position is always in
24
+ // view. Scan-to-verify, can't-skip ordering, and a short-pick exception that
25
+ // flags-and-advances without stalling the run.
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ interface PickLine {
29
+ id: string;
30
+ zone: string;
31
+ bin: string;
32
+ sku: string;
33
+ item: string;
34
+ qty: number;
35
+ status: "pending" | "picked" | "short";
36
+ /** Actual units taken — = qty when picked, < qty when short. */
37
+ picked?: number;
38
+ }
39
+
40
+ // Ordered by pick path — the run walks the operator down the aisles in sequence.
41
+ const WAVE: PickLine[] = [
42
+ { id: "L1", zone: "Aisle A", bin: "A-12-03", sku: "RM-0012", item: "Kraft paper 175gsm, 1.6m", qty: 8, status: "pending" },
43
+ { id: "L2", zone: "Aisle A", bin: "A-14-08", sku: "RM-0018", item: "Medium paper 115gsm, 1.4m", qty: 12, status: "pending" },
44
+ { id: "L3", zone: "Aisle B", bin: "B-03-01", sku: "FG-0203", item: "Carton box 600×400×400, 5-ply", qty: 50, status: "pending" },
45
+ { id: "L4", zone: "Aisle B", bin: "B-05-07", sku: "FG-0218", item: "Carton box 350×250×200, 3-ply", qty: 40, status: "pending" },
46
+ { id: "L5", zone: "Aisle C", bin: "C-01-02", sku: "SUP-0007", item: "Clear packing tape 48mm", qty: 24, status: "pending" },
47
+ { id: "L6", zone: "Aisle C", bin: "C-02-09", sku: "SUP-0011", item: "PP strapping 12mm, 10kg roll", qty: 6, status: "pending" },
48
+ { id: "L7", zone: "Aisle C", bin: "C-04-01", sku: "FG-0226", item: "Offset box 250×180×90, 4-color", qty: 30, status: "pending" },
49
+ ];
50
+
51
+ const SHORT_REASONS = [
52
+ { label: "Out of stock", value: "oos" },
53
+ { label: "Damaged", value: "damaged" },
54
+ { label: "Location empty", value: "empty" },
55
+ ];
56
+
57
+ // The exception branch — record fewer units than asked + a reason, then advance.
58
+ // A short never blocks the run; the shortfall is flagged for backfill.
59
+ function ShortGate({ line, onShort }: { line: PickLine; onShort: (actual: number) => void }) {
60
+ const [open, setOpen] = useState(false);
61
+ const [actual, setActual] = useState(0);
62
+ const [reason, setReason] = useState("oos");
63
+ return (
64
+ <Popover open={open} onOpenChange={setOpen} side="top" align="start">
65
+ <PopoverTrigger>
66
+ <Button title="Can't pick all" color="muted" />
67
+ </PopoverTrigger>
68
+ <PopoverContent style={{ width: 300 }} disableBodyScroll>
69
+ <View style={{ gap: 14 }}>
70
+ <View style={{ gap: 2 }}>
71
+ <Text size="sm" weight="semibold">{`Short pick · ${line.bin}`}</Text>
72
+ <Text size="xs" color="muted">Record what you actually took — the rest is flagged for backfill.</Text>
73
+ </View>
74
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
75
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{`Picked (of ${line.qty})`}</Text>
76
+ <Counter value={actual} min={0} max={line.qty} onValueChange={setActual} accessibilityLabel="units actually picked" />
77
+ </View>
78
+ <ChipGroup accessibilityLabel="Short reason" options={SHORT_REASONS} value={reason} onValueChange={setReason} />
79
+ </View>
80
+ <PopoverFooter>
81
+ <Button title="Record short" color="danger" onPress={() => { setOpen(false); onShort(actual); }} />
82
+ </PopoverFooter>
83
+ </PopoverContent>
84
+ </Popover>
85
+ );
86
+ }
87
+
88
+ // The hero — the one task in front of the operator right now.
89
+ function CurrentTask({ line, index, total, onConfirm, onShort }: {
90
+ line: PickLine; index: number; total: number; onConfirm: () => void; onShort: (actual: number) => void;
91
+ }) {
92
+ const [scan, setScan] = useState("");
93
+ const matched = scan.trim().toUpperCase() === line.bin.toUpperCase();
94
+ return (
95
+ <Card style={{ padding: 0 }}>
96
+ <View style={{ padding: 24, gap: 18 }}>
97
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
98
+ <Badge label={line.zone} color="blue" />
99
+ <Text size="xs" color="muted" tabular style={{ flex: 1, textAlign: "right" }}>{`Line ${index + 1} of ${total}`}</Text>
100
+ </View>
101
+ {/* the bin is the biggest thing on screen — it's where you walk */}
102
+ <View style={{ gap: 2 }}>
103
+ <Text size="xs" color="muted" transform="uppercase">Go to bin</Text>
104
+ <Text size="xxl" weight="semibold" tabular>{line.bin}</Text>
105
+ </View>
106
+ <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 16, flexWrap: "wrap" }}>
107
+ <View style={{ gap: 2, flexGrow: 1, flexBasis: 200 }}>
108
+ <Text size="md" weight="medium">{line.item}</Text>
109
+ <Text size="sm" color="muted" tabular>{line.sku}</Text>
110
+ </View>
111
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
112
+ <Text size="sm" color="muted">Pick</Text>
113
+ <Text size="xl" weight="semibold" tabular>{line.qty}</Text>
114
+ <Text size="sm" color="muted">units</Text>
115
+ </View>
116
+ </View>
117
+ {/* scan-to-verify — confirm you're at the right shelf before the count */}
118
+ <ScanField
119
+ value={scan}
120
+ onChangeText={setScan}
121
+ status={matched ? "match" : scan.trim() ? "mismatch" : "idle"}
122
+ matchHint="Location verified — confirm the count"
123
+ mismatchHint={`Wrong bin — walk to ${line.bin}`}
124
+ placeholder={`Scan or type bin ${line.bin}`}
125
+ onScan={() => { if (matched) onConfirm(); }}
126
+ accessibilityLabel="Scan bin to verify location"
127
+ />
128
+ </View>
129
+ <Divider />
130
+ <View style={{ padding: 16, flexDirection: "row", alignItems: "center", gap: 12 }}>
131
+ <ShortGate line={line} onShort={onShort} />
132
+ <View style={{ flex: 1 }} />
133
+ <Button title={`Confirm ${line.qty}`} color="primary" onPress={onConfirm} />
134
+ </View>
135
+ </Card>
136
+ );
137
+ }
138
+
139
+ function Complete({ lines, onReset }: { lines: PickLine[]; onReset: () => void }) {
140
+ const shorts = lines.filter((l) => l.status === "short").length;
141
+ const units = lines.reduce((s, l) => s + (l.status === "short" ? l.picked ?? 0 : l.qty), 0);
142
+ return (
143
+ <Card style={{ padding: 0 }}>
144
+ <CompletionState
145
+ title="Wave picked"
146
+ summary={`${lines.length} lines · ${units} units${shorts > 0 ? ` · ${shorts} short` : ""}`}
147
+ >
148
+ <Button title="Start next wave" color="muted" onPress={onReset} />
149
+ <Button title="Hand to packing" color="primary" onPress={() => {}} />
150
+ </CompletionState>
151
+ </Card>
152
+ );
153
+ }
154
+
155
+ // The right rail — the whole run at a glance: done · now · up next. Read-only;
156
+ // the run is sequential, so position is shown, not chosen.
157
+ function RunPath({ lines, currentIndex }: { lines: PickLine[]; currentIndex: number }) {
158
+ const steps: StepListItem[] = lines.map((l, i) => ({
159
+ id: l.id,
160
+ status: l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "pending",
161
+ title: l.bin,
162
+ subtitle: l.item,
163
+ trailing:
164
+ l.status === "short" ? (
165
+ <Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
166
+ ) : (
167
+ <Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
168
+ ),
169
+ }));
170
+ return (
171
+ <Card style={{ padding: 0 }}>
172
+ <View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
173
+ <Text size="xs" color="muted" transform="uppercase">{`Pick path · ${lines.length} lines`}</Text>
174
+ </View>
175
+ <Divider />
176
+ <View style={{ paddingHorizontal: 16, paddingVertical: 14 }}>
177
+ <StepList steps={steps} accessibilityLabel="Pick path" />
178
+ </View>
179
+ </Card>
180
+ );
181
+ }
182
+
183
+ export function TplPick() {
184
+ const [lines, setLines] = useState<PickLine[]>(WAVE);
185
+
186
+ const idx = lines.findIndex((l) => l.status === "pending");
187
+ const current = idx >= 0 ? lines[idx] : null;
188
+ const done = lines.filter((l) => l.status !== "pending");
189
+
190
+ const totalUnits = lines.reduce((s, l) => s + l.qty, 0);
191
+ const pickedUnits = lines.reduce((s, l) => s + (l.status === "picked" ? l.qty : l.status === "short" ? l.picked ?? 0 : 0), 0);
192
+ const shorts = lines.filter((l) => l.status === "short").length;
193
+
194
+ const setStatus = (id: string, status: PickLine["status"], picked: number) =>
195
+ setLines((prev) => prev.map((l) => (l.id === id ? { ...l, status, picked } : l)));
196
+
197
+ return (
198
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
199
+ <View style={{ width: "100%", maxWidth: 1000, alignSelf: "center", gap: 16 }}>
200
+ {/* header — the wave + the cart it's filling */}
201
+ <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12, flexWrap: "wrap" }}>
202
+ <View style={{ gap: 2, flex: 1 }}>
203
+ <Text size="xl" weight="semibold">Pick wave W-0142</Text>
204
+ <Text size="sm" color="muted">7 lines for 3 outbound orders · Cart C-07</Text>
205
+ </View>
206
+ <Button title="Pause wave" color="muted" onPress={() => {}} />
207
+ </View>
208
+
209
+ {/* run progress — the one number that says how close to done */}
210
+ <View style={{ gap: 6 }}>
211
+ <View style={{ flexDirection: "row", alignItems: "center" }}>
212
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{`${done.length} of ${lines.length} lines · ${pickedUnits} of ${totalUnits} units`}</Text>
213
+ {shorts > 0 ? <Text size="sm" color="danger" tabular>{`${shorts} short`}</Text> : null}
214
+ </View>
215
+ <ProgressBar value={done.length} max={lines.length} format="none" color={solid("blue")} />
216
+ </View>
217
+
218
+ {/* two panes — the current task large, the whole path alongside it */}
219
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
220
+ <View style={{ flexGrow: 1.6, flexBasis: 440 }}>
221
+ {current ? (
222
+ <CurrentTask
223
+ line={current}
224
+ index={idx}
225
+ total={lines.length}
226
+ onConfirm={() => setStatus(current.id, "picked", current.qty)}
227
+ onShort={(actual) => setStatus(current.id, actual >= current.qty ? "picked" : "short", actual)}
228
+ />
229
+ ) : (
230
+ <Complete lines={lines} onReset={() => setLines(WAVE)} />
231
+ )}
232
+ </View>
233
+ <View style={{ flexGrow: 1, flexBasis: 320 }}>
234
+ <RunPath lines={lines} currentIndex={idx} />
235
+ </View>
236
+ </View>
237
+ </View>
238
+ </ScrollView>
239
+ );
240
+ }
@@ -0,0 +1,210 @@
1
+ import { useRef, useState } from "react";
2
+ import { ScrollView, View, type TextInput as RNTextInput } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { Alert } from "@lotics/ui/alert";
7
+ import { Card, CardBody, CardHeader, CardHeaderMeta, CardHeaderTitle } from "@lotics/ui/card";
8
+ import { Divider } from "@lotics/ui/divider";
9
+ import { Icon, type IconName } from "@lotics/ui/icon";
10
+ import { FormField } from "@lotics/ui/form_field";
11
+ import { TextInputField } from "@lotics/ui/text_input_field";
12
+ import { Combobox } from "@lotics/ui/combobox";
13
+ import type { PickerOption } from "@lotics/ui/picker";
14
+ import { SegmentedControl } from "@lotics/ui/segmented_control";
15
+ import { Counter } from "@lotics/ui/counter";
16
+ import { EmptyState } from "@lotics/ui/empty_state";
17
+
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ // Template · Quick capture — the SPEED surface, opposite of the heavy form: one
20
+ // compact row of inputs, Enter to add, the field keeps focus and the contact
21
+ // carries over so the next touchpoint is two keystrokes away. Every entry drops
22
+ // into the log below (newest first, undoable). A `SegmentedControl` mode, a
23
+ // `Combobox`-as-select, a `Counter` for the bounded duration. No screen-wide
24
+ // Save — each entry commits on its own.
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+
27
+ type ActType = "call" | "email" | "meeting" | "note";
28
+
29
+ const ACT: Record<ActType, { label: string; icon: IconName }> = {
30
+ call: { label: "Call", icon: "phone" },
31
+ email: { label: "Email", icon: "mail" },
32
+ meeting: { label: "Meeting", icon: "calendar" },
33
+ note: { label: "Note", icon: "message-square" },
34
+ };
35
+
36
+ const TYPE_OPTIONS = (Object.keys(ACT) as ActType[]).map((v) => ({ label: ACT[v].label, value: v }));
37
+
38
+ interface Contact {
39
+ id: string;
40
+ name: string;
41
+ company: string;
42
+ }
43
+
44
+ const CONTACTS: Contact[] = [
45
+ { id: "c1", name: "Mara Lindqvist", company: "Northwind Traders" },
46
+ { id: "c2", name: "Diego Alvarez", company: "Harbor Freight Lines" },
47
+ { id: "c3", name: "Priya Nair", company: "Summit Packaging Co." },
48
+ { id: "c4", name: "Tom Becker", company: "Atlas Distribution" },
49
+ { id: "c5", name: "Lena Fischer", company: "Bluewater Logistics" },
50
+ { id: "c6", name: "Sara Cohen", company: "Meridian Retail Group" },
51
+ ];
52
+
53
+ const CONTACT_OPTIONS: PickerOption<string, Contact>[] = CONTACTS.map((c) => ({
54
+ value: c.id,
55
+ label: c.name,
56
+ data: c,
57
+ }));
58
+
59
+ interface Entry {
60
+ id: string;
61
+ type: ActType;
62
+ contact: Contact;
63
+ note: string;
64
+ minutes: number;
65
+ time: string;
66
+ }
67
+
68
+ const SEED: Entry[] = [
69
+ { id: "e1", type: "call", contact: CONTACTS[2], note: "Reviewed Q3 volume, wants a revised quote by Friday", minutes: 15, time: "10:05" },
70
+ { id: "e2", type: "email", contact: CONTACTS[0], note: "Sent updated pallet spec sheet", minutes: 0, time: "09:40" },
71
+ { id: "e3", type: "meeting", contact: CONTACTS[4], note: "Warehouse walkthrough — flagged dock height", minutes: 45, time: "09:12" },
72
+ ];
73
+
74
+ const fmtDuration = (m: number) => (m === 0 ? "—" : m >= 60 ? `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}` : `${m}m`);
75
+
76
+ export function TplQuick() {
77
+ const id = useRef(1);
78
+ const noteRef = useRef<RNTextInput>(null);
79
+
80
+ const [type, setType] = useState<ActType>("call");
81
+ const [contact, setContact] = useState<PickerOption<string, Contact> | null>(null);
82
+ const [note, setNote] = useState("");
83
+ const [minutes, setMinutes] = useState(15);
84
+ const [entries, setEntries] = useState<Entry[]>(SEED);
85
+
86
+ const canAdd = contact !== null && note.trim().length > 0;
87
+
88
+ const add = () => {
89
+ if (!canAdd || !contact?.data) return;
90
+ const entry: Entry = {
91
+ id: `e_${(id.current += 1)}`,
92
+ type,
93
+ contact: contact.data,
94
+ note: note.trim(),
95
+ minutes,
96
+ time: "Just now",
97
+ };
98
+ setEntries((prev) => [entry, ...prev]);
99
+ // Keep type + contact for the next touchpoint; clear only the note + duration
100
+ // and re-focus the note so logging again is immediate.
101
+ setNote("");
102
+ setMinutes(type === "email" ? 0 : 15);
103
+ noteRef.current?.focus();
104
+ };
105
+
106
+ return (
107
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
108
+ <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
109
+ <View style={{ gap: 2 }}>
110
+ <Text size="xl" weight="semibold">Log activity</Text>
111
+ <Text size="sm" color="muted">Capture a touchpoint in seconds — pick the type, the contact, type the note, press Enter</Text>
112
+ </View>
113
+
114
+ {/* the capture row */}
115
+ <Card style={{ padding: 0 }}>
116
+ <CardBody>
117
+ <SegmentedControl accessibilityLabel="Activity type" options={TYPE_OPTIONS} value={type} onValueChange={setType} style={{ marginBottom: 16 }} />
118
+ <View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
119
+ <FormField label="Contact" style={{ flexGrow: 1, flexBasis: 240 }}>
120
+ <Combobox
121
+ options={CONTACT_OPTIONS}
122
+ value={contact}
123
+ onValueChange={setContact}
124
+ getOptionDescription={(o) => o.data?.company}
125
+ placeholder="Select a contact"
126
+ accessibilityLabel="Contact"
127
+ />
128
+ </FormField>
129
+ <FormField label="Duration" style={{ flexGrow: 0, flexBasis: 180 }}>
130
+ <View style={{ minHeight: 40, justifyContent: "center" }}>
131
+ <Counter value={minutes} onValueChange={setMinutes} min={0} max={240} step={15} accessibilityLabel="Duration" format={fmtDuration} />
132
+ </View>
133
+ </FormField>
134
+ </View>
135
+ <FormField label="Note">
136
+ <TextInputField
137
+ ref={noteRef}
138
+ value={note}
139
+ onChangeText={setNote}
140
+ placeholder="What happened? (press Enter to log)"
141
+ autoFocus
142
+ onKeyPress={(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
143
+ if (e.nativeEvent.key === "Enter") {
144
+ e.preventDefault();
145
+ add();
146
+ }
147
+ }}
148
+ />
149
+ </FormField>
150
+ </CardBody>
151
+ <Divider />
152
+ <View style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 20, paddingVertical: 12, gap: 8 }}>
153
+ <Text size="xs" color="muted" style={{ flex: 1 }}>
154
+ {contact?.data ? `${ACT[type].label} · ${contact.data.name}` : "Pick a contact to log against"}
155
+ </Text>
156
+ <Button title="Log activity" color="primary" icon="plus" disabled={!canAdd} onPress={add} />
157
+ </View>
158
+ </Card>
159
+
160
+ {/* the running log */}
161
+ <Card style={{ padding: 0 }}>
162
+ <CardHeader>
163
+ <CardHeaderTitle>Today</CardHeaderTitle>
164
+ <CardHeaderMeta>{entries.length} logged</CardHeaderMeta>
165
+ </CardHeader>
166
+ {entries.length === 0 ? (
167
+ <EmptyState icon="message-square" message="Nothing logged yet" hint="Your activity for the day appears here" />
168
+ ) : (
169
+ entries.map((e, i) => (
170
+ <View key={e.id}>
171
+ {i > 0 ? <Divider /> : null}
172
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12 }}>
173
+ <View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
174
+ <Icon name={ACT[e.type].icon} size={16} color={colors.zinc[600]} />
175
+ </View>
176
+ <View style={{ flex: 1, gap: 1 }}>
177
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
178
+ <Text weight="medium" numberOfLines={1}>{e.contact.name}</Text>
179
+ <Text size="xs" color="muted">· {e.contact.company}</Text>
180
+ </View>
181
+ <Text size="sm" color="muted" numberOfLines={1}>{e.note}</Text>
182
+ </View>
183
+ <View style={{ alignItems: "flex-end", width: 76 }}>
184
+ <Text size="xs" color="muted" tabular>{e.time}</Text>
185
+ {e.minutes > 0 ? <Text size="xs" color="muted" tabular>{fmtDuration(e.minutes)}</Text> : null}
186
+ </View>
187
+ <Button
188
+ title="Delete"
189
+ color="secondary"
190
+ accessibilityLabel={`Delete ${e.contact.name} ${ACT[e.type].label.toLowerCase()}`}
191
+ onPress={() =>
192
+ Alert.alert(
193
+ "Delete activity?",
194
+ `Remove the ${ACT[e.type].label.toLowerCase()} logged for ${e.contact.name}. This can't be undone.`,
195
+ [
196
+ { text: "Cancel", style: "cancel" },
197
+ { text: "Delete", style: "destructive", onPress: () => setEntries((prev) => prev.filter((x) => x.id !== e.id)) },
198
+ ],
199
+ )
200
+ }
201
+ />
202
+ </View>
203
+ </View>
204
+ ))
205
+ )}
206
+ </Card>
207
+ </View>
208
+ </ScrollView>
209
+ );
210
+ }