From 0bba2350a40045bec59d829e51b16bfff80152ea Mon Sep 17 00:00:00 2001 From: TSVRN9 <66744142+TSVRN9@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:51:01 -0400 Subject: [PATCH 1/2] mobile: port the design system + restyle all screens (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port web's app.css design tokens (maroon/gold/paper/cream/ink palette, display/body/mono type roles, radii) to a plain RN StyleSheet theme (mobile/src/lib/theme.ts) plus a small set of shared components mirroring web's component layer: Card, Button (primary/secondary/ ghost, sm size), Badge, Stat, EmptyState (mobile/src/components/ui/). Theme and components were written red-first (see PR/issue for the red output). Restyle every screen (index, halls/[slug], today, filters, favorites, rank, friends, notifications, events, press, newsletter) plus the root Stack header to use the new tokens and components instead of ad hoc hex colors. index.tsx is rebuilt as a Today-first home per #64's IA: today's macro stats (device-local, deep-linking to /today) at top, dining halls below (favorite-star behavior unchanged), then quick links to every other screen (nothing dropped from the old nav grid — same destinations, different layout). Behavior is unchanged: same storage calls, same RLS/Supabase queries, same favorite/log/rank logic. The only functional-adjacent changes are layout fixes needed to host the new components without breaking scroll (FlatList/ScrollView wrapping, halls list on the home screen switched from FlatList to a plain map since it now sits inside a ScrollView). react-test-renderer + @types/react-test-renderer added as devDependencies so component tests can render RN trees under jest-expo/tsc — both were already transitive deps of jest-expo at the exact same version (pnpm-lock.yaml already pinned them), so this just promotes an already-resolved package to a direct devDependency rather than adding anything new to the dependency graph. npx tsc --noEmit and npx jest are both clean in mobile/. --- mobile/package.json | 2 + mobile/src/app/_layout.tsx | 10 +- mobile/src/app/events.tsx | 40 ++-- mobile/src/app/favorites.tsx | 32 ++- mobile/src/app/filters.tsx | 43 ++-- mobile/src/app/friends.tsx | 203 ++++++++++-------- mobile/src/app/halls/[slug].tsx | 86 +++++--- mobile/src/app/index.tsx | 206 +++++++++++++------ mobile/src/app/newsletter.tsx | 30 ++- mobile/src/app/notifications.tsx | 45 ++-- mobile/src/app/press.tsx | 38 ++-- mobile/src/app/rank.tsx | 68 +++--- mobile/src/app/today.tsx | 133 ++++++++---- mobile/src/components/ui/Badge.test.tsx | 13 ++ mobile/src/components/ui/Badge.tsx | 30 +++ mobile/src/components/ui/Button.test.tsx | 64 ++++++ mobile/src/components/ui/Button.tsx | 64 ++++++ mobile/src/components/ui/Card.test.tsx | 35 ++++ mobile/src/components/ui/Card.tsx | 16 ++ mobile/src/components/ui/EmptyState.test.tsx | 23 +++ mobile/src/components/ui/EmptyState.tsx | 40 ++++ mobile/src/components/ui/Stat.test.tsx | 28 +++ mobile/src/components/ui/Stat.tsx | 36 ++++ mobile/src/components/ui/index.ts | 5 + mobile/src/lib/theme.test.ts | 36 ++++ mobile/src/lib/theme.ts | 73 +++++++ pnpm-lock.yaml | 6 + 27 files changed, 1080 insertions(+), 325 deletions(-) create mode 100644 mobile/src/components/ui/Badge.test.tsx create mode 100644 mobile/src/components/ui/Badge.tsx create mode 100644 mobile/src/components/ui/Button.test.tsx create mode 100644 mobile/src/components/ui/Button.tsx create mode 100644 mobile/src/components/ui/Card.test.tsx create mode 100644 mobile/src/components/ui/Card.tsx create mode 100644 mobile/src/components/ui/EmptyState.test.tsx create mode 100644 mobile/src/components/ui/EmptyState.tsx create mode 100644 mobile/src/components/ui/Stat.test.tsx create mode 100644 mobile/src/components/ui/Stat.tsx create mode 100644 mobile/src/components/ui/index.ts create mode 100644 mobile/src/lib/theme.test.ts create mode 100644 mobile/src/lib/theme.ts diff --git a/mobile/package.json b/mobile/package.json index 81980164..34f31913 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -29,7 +29,9 @@ "devDependencies": { "@types/jest": "^29.5.14", "@types/react": "~19.2.2", + "@types/react-test-renderer": "19.1.0", "jest-expo": "~57.0.4", + "react-test-renderer": "19.2.3", "typescript": "~6.0.3" }, "scripts": { diff --git a/mobile/src/app/_layout.tsx b/mobile/src/app/_layout.tsx index 8450ac74..42bb8cbf 100644 --- a/mobile/src/app/_layout.tsx +++ b/mobile/src/app/_layout.tsx @@ -1,8 +1,16 @@ import { Stack } from "expo-router"; +import { colors, fonts } from "../lib/theme"; export default function RootLayout() { return ( - + diff --git a/mobile/src/app/events.tsx b/mobile/src/app/events.tsx index c836265e..5ae08fce 100644 --- a/mobile/src/app/events.tsx +++ b/mobile/src/app/events.tsx @@ -1,6 +1,8 @@ import { fetchEvents, type DiningEvent } from "@udine/shared"; import { useEffect, useState } from "react"; -import { ActivityIndicator, FlatList, Image, Linking, Pressable, StyleSheet, Text } from "react-native"; +import { ActivityIndicator, FlatList, Image, Linking, Pressable, StyleSheet, Text, View } from "react-native"; +import { Card, EmptyState } from "../components/ui"; +import { colors, fonts, spacing, withOpacity } from "../lib/theme"; export default function EventsScreen() { const [items, setItems] = useState(null); @@ -11,7 +13,7 @@ export default function EventsScreen() { }, []); if (error) return Failed to load events: {error}; - if (!items) return ; + if (!items) return ; function open(item: DiningEvent) { const url = item.externalLink || item.pdfLink; @@ -20,28 +22,34 @@ export default function EventsScreen() { return ( `${item.title}-${i}`} renderItem={({ item }) => ( - open(item)}> - {!!item.featuredImage && } - - {item.isFeatured ? "★ " : ""} - {item.title} - + open(item)}> + + {!!item.featuredImage && } + + {item.isFeatured ? "★ " : ""} + {item.title} + + )} - ListEmptyComponent={No events right now.} + ItemSeparatorComponent={() => } + ListEmptyComponent={} /> ); } const styles = StyleSheet.create({ - container: { flex: 1 }, - row: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#ccc" }, - image: { width: "100%", height: 140, borderRadius: 6, marginBottom: 8, backgroundColor: "#eee" }, - title: { fontSize: 16, fontWeight: "600" }, - error: { padding: 16, color: "red" }, - empty: { padding: 16, color: "#666" }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + loading: { flex: 1, backgroundColor: colors.cream100 }, + row: { padding: spacing(3) }, + image: { width: "100%", height: 140, borderRadius: 6, marginBottom: spacing(2), backgroundColor: withOpacity(colors.ink900, 8) }, + title: { fontSize: 16, fontWeight: "700", fontFamily: fonts.display, color: colors.maroon900 }, + error: { padding: spacing(4), color: "#b00020", fontFamily: fonts.body }, + separator: { height: spacing(2) }, }); diff --git a/mobile/src/app/favorites.tsx b/mobile/src/app/favorites.tsx index b71ca61c..8009543d 100644 --- a/mobile/src/app/favorites.tsx +++ b/mobile/src/app/favorites.tsx @@ -2,6 +2,8 @@ import { DINING_HALLS, type Favorite } from "@udine/shared"; import { useFocusEffect } from "expo-router"; import { useCallback, useState } from "react"; import { FlatList, StyleSheet, Text, View } from "react-native"; +import { Badge, Card, EmptyState } from "../components/ui"; +import { colors, fonts, spacing } from "../lib/theme"; import { SqliteFavoritesStorage } from "../lib/favoritesStorage"; const storage = new SqliteFavoritesStorage(); @@ -17,24 +19,34 @@ export default function FavoritesScreen() { return ( `${f.type}-${i}`} + ListHeaderComponent={ + + Favorites + + + } renderItem={({ item }) => ( - - {item.type === "dish" ? "Dish" : "Hall"} + + {item.type === "dish" ? "Dish" : "Hall"} {item.type === "dish" ? item.dishName : DINING_HALLS.find((h) => h.tid === item.hallTid)?.name ?? item.hallTid} - + )} - ListEmptyComponent={No favorites yet — star a dish or dining hall to add one.} + ItemSeparatorComponent={() => } + ListEmptyComponent={} /> ); } const styles = StyleSheet.create({ - container: { flex: 1 }, - row: { flexDirection: "row", alignItems: "center", padding: 12, gap: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#ccc" }, - badge: { fontSize: 11, fontWeight: "700", color: "#208AEF", borderWidth: 1, borderColor: "#208AEF", borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 }, - rowText: { fontSize: 16 }, - empty: { padding: 16, color: "#666" }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + pageTitle: { fontFamily: fonts.display, fontSize: 24, fontWeight: "700", textTransform: "uppercase", color: colors.maroon900 }, + rule: { marginTop: spacing(2), marginBottom: spacing(2), height: 0, borderTopWidth: 4, borderBottomWidth: 1, borderColor: colors.gold500 }, + row: { flexDirection: "row", alignItems: "center", padding: spacing(3), gap: spacing(2.5) }, + rowText: { fontSize: 15, fontFamily: fonts.body, color: colors.ink900 }, + separator: { height: spacing(2) }, }); diff --git a/mobile/src/app/filters.tsx b/mobile/src/app/filters.tsx index cfbcf7f8..1e69e97c 100644 --- a/mobile/src/app/filters.tsx +++ b/mobile/src/app/filters.tsx @@ -1,6 +1,8 @@ import { DINING_HALLS, fetchMenu, type FoodPreferences } from "@udine/shared"; import { useEffect, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { EmptyState } from "../components/ui"; +import { colors, fonts, spacing, withOpacity } from "../lib/theme"; import { getPreferences, setPreferences } from "../lib/preferences"; export default function FiltersScreen() { @@ -33,40 +35,57 @@ export default function FiltersScreen() { setPreferences(updated); } - if (!allergens) return ; + if (!allergens) return ; return ( - + + Dietary Filters + + Avoid allergens + {allergens.map((a) => ( toggleAllergen(a)}> {a} ))} - {allergens.length === 0 && No allergen data found in today's menus.} + {allergens.length === 0 && } Require diet tags + {dietTags.map((t) => ( toggleDietTag(t)}> {t} ))} - {dietTags.length === 0 && No diet-tag data found in today's menus.} + {dietTags.length === 0 && } ); } const styles = StyleSheet.create({ - container: { flex: 1, padding: 16 }, - sectionTitle: { fontSize: 16, fontWeight: "700", marginTop: 12, marginBottom: 8 }, - chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, - chip: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 16, borderWidth: 1, borderColor: "#208AEF" }, - chipActive: { backgroundColor: "#208AEF" }, - chipText: { color: "#208AEF" }, - chipTextActive: { color: "white" }, - empty: { color: "#666" }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + loading: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + pageTitle: { fontFamily: fonts.display, fontSize: 24, fontWeight: "700", textTransform: "uppercase", color: colors.maroon900 }, + rule: { marginTop: spacing(2), marginBottom: spacing(2), height: 0, borderTopWidth: 4, borderBottomWidth: 1, borderColor: colors.gold500 }, + sectionTitle: { + marginTop: spacing(6), + fontFamily: fonts.display, + fontSize: 15, + fontWeight: "700", + letterSpacing: 1, + textTransform: "uppercase", + color: colors.maroon900, + }, + thinRule: { marginTop: spacing(1), marginBottom: spacing(3), height: 1, backgroundColor: withOpacity(colors.ink900, 25) }, + chipRow: { flexDirection: "row", flexWrap: "wrap", gap: spacing(2) }, + chip: { paddingVertical: spacing(1.5), paddingHorizontal: spacing(3), borderRadius: 999, borderWidth: 1, borderColor: colors.maroon600 }, + chipActive: { backgroundColor: colors.maroon600 }, + chipText: { color: colors.maroon600, fontFamily: fonts.body, fontSize: 13, fontWeight: "600" }, + chipTextActive: { color: colors.paper50 }, }); diff --git a/mobile/src/app/friends.tsx b/mobile/src/app/friends.tsx index 01340a96..5bcf5b11 100644 --- a/mobile/src/app/friends.tsx +++ b/mobile/src/app/friends.tsx @@ -3,6 +3,8 @@ import type { Session } from "@supabase/supabase-js"; import { useCallback, useEffect, useState } from "react"; import { useFocusEffect } from "expo-router"; import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"; +import { Button, Card, EmptyState } from "../components/ui"; +import { colors, fonts, spacing, withOpacity } from "../lib/theme"; import { supabase } from "../lib/supabase"; type Profile = { user_id: string; display_name: string }; @@ -102,8 +104,8 @@ export default function FriendsScreen() { if (!session) { return ( - - Sign in to add friends and send pings. + + ); } @@ -113,98 +115,121 @@ export default function FriendsScreen() { const accepted = friendships.filter((f) => f.status === "accepted"); return ( - - - Find friends - - {searchResults.map((p) => ( - - {p.display_name} - requestFriend(p.user_id)}> - Add friend - - - ))} - - Friend requests - {pending.length === 0 && No pending requests.} - {pending.map((f) => { - const other = profilesById.get(otherUserId(f, myId)); - return ( - - {other?.display_name ?? "..."} - {f.requested_by === myId ? ( - pending - ) : ( - acceptFriend(f)}> - Accept - - )} - - ); - })} - - Your friends - {accepted.length === 0 && No friends yet.} - {accepted.map((f) => { - const otherId = otherUserId(f, myId); - const other = profilesById.get(otherId); - return ( - - {other?.display_name ?? "..."} - - {DINING_HALLS.map((hall) => ( - setPingHallTid((prev) => ({ ...prev, [otherId]: prev[otherId] === hall.tid ? null : hall.tid }))} - > - {hall.name} - - ))} - - setPingMessage((prev) => ({ ...prev, [otherId]: text }))} - placeholder="message (optional)" - /> - sendPing(otherId)}> - Ping "come eat with me" + + Friends + + + Find friends + + + {searchResults.map((p) => ( + + {p.display_name} + requestFriend(p.user_id)}> + Add friend + + + ))} + + Friend requests + + {pending.length === 0 && No pending requests.} + {pending.map((f) => { + const other = profilesById.get(otherUserId(f, myId)); + return ( + + {other?.display_name ?? "..."} + {f.requested_by === myId ? ( + pending + ) : ( + acceptFriend(f)}> + Accept + )} + + ); + })} + + Your friends + + {accepted.length === 0 && No friends yet.} + {accepted.map((f) => { + const otherId = otherUserId(f, myId); + const other = profilesById.get(otherId); + return ( + + {other?.display_name ?? "..."} + + {DINING_HALLS.map((hall) => ( + setPingHallTid((prev) => ({ ...prev, [otherId]: prev[otherId] === hall.tid ? null : hall.tid }))} + > + {hall.name} + + ))} - ); - })} - - Pings you've received - {inbox.length === 0 && No pings yet.} - {inbox.map((p) => ( - - {profilesById.get(p.sender_id)?.display_name ?? "Someone"} wants to eat {p.hall_tid ? `at ${hallName(p.hall_tid)}` : ""} - {p.message ? ` — "${p.message}"` : ""} - - ))} - + setPingMessage((prev) => ({ ...prev, [otherId]: text }))} + placeholder="message (optional)" + /> + + + ); + })} + + Pings you've received + + {inbox.length === 0 && No pings yet.} + {inbox.map((p) => ( + + {profilesById.get(p.sender_id)?.display_name ?? "Someone"} wants to eat {p.hall_tid ? `at ${hallName(p.hall_tid)}` : ""} + {p.message ? ` — "${p.message}"` : ""} + + ))} ); } const styles = StyleSheet.create({ - container: { flex: 1, padding: 16 }, - sectionTitle: { fontSize: 18, fontWeight: "600", marginTop: 20, marginBottom: 8 }, - input: { borderWidth: StyleSheet.hairlineWidth, borderColor: "#ccc", borderRadius: 8, padding: 10 }, - row: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#eee" }, - rowText: { fontSize: 16 }, - actionText: { color: "#208AEF", fontWeight: "600" }, - pendingText: { color: "#888" }, - empty: { color: "#888" }, - friendCard: { paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#eee" }, - chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginVertical: 6 }, - chip: { borderWidth: 1, borderColor: "#208AEF", borderRadius: 16, paddingVertical: 4, paddingHorizontal: 10 }, - chipActive: { backgroundColor: "#208AEF" }, - chipText: { color: "#208AEF" }, - chipTextActive: { color: "white" }, - pingButton: { backgroundColor: "#208AEF", borderRadius: 8, padding: 10, alignItems: "center", marginTop: 4 }, - pingButtonText: { color: "white", fontWeight: "600" }, - pingRow: { paddingVertical: 6 }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + pageTitle: { fontFamily: fonts.display, fontSize: 24, fontWeight: "700", textTransform: "uppercase", color: colors.maroon900 }, + rule: { marginTop: spacing(2), marginBottom: spacing(2), height: 0, borderTopWidth: 4, borderBottomWidth: 1, borderColor: colors.gold500 }, + sectionTitle: { + marginTop: spacing(6), + fontFamily: fonts.display, + fontSize: 15, + fontWeight: "700", + letterSpacing: 1, + textTransform: "uppercase", + color: colors.maroon900, + }, + thinRule: { marginTop: spacing(1), marginBottom: spacing(3), height: 1, backgroundColor: withOpacity(colors.ink900, 25) }, + input: { + borderWidth: 1, + borderColor: withOpacity(colors.ink900, 25), + borderRadius: 2, + padding: spacing(2.5), + fontFamily: fonts.body, + color: colors.ink900, + backgroundColor: colors.paper50, + }, + row: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: spacing(3), marginTop: spacing(2) }, + rowText: { fontSize: 15, fontFamily: fonts.body, color: colors.ink900 }, + actionText: { color: colors.maroon600, fontFamily: fonts.body, fontWeight: "600" }, + pendingText: { color: withOpacity(colors.ink900, 50), fontFamily: fonts.body }, + empty: { color: withOpacity(colors.ink900, 55), fontFamily: fonts.body, marginTop: spacing(1) }, + friendCard: { padding: spacing(3), marginTop: spacing(2), gap: spacing(2) }, + chipRow: { flexDirection: "row", flexWrap: "wrap", gap: spacing(1.5) }, + chip: { borderWidth: 1, borderColor: colors.maroon600, borderRadius: 999, paddingVertical: spacing(1), paddingHorizontal: spacing(2.5) }, + chipActive: { backgroundColor: colors.maroon600 }, + chipText: { color: colors.maroon600, fontFamily: fonts.body, fontSize: 13 }, + chipTextActive: { color: colors.paper50 }, + pingRow: { fontFamily: fonts.body, fontSize: 14, color: colors.ink900, paddingVertical: spacing(1.5) }, }); diff --git a/mobile/src/app/halls/[slug].tsx b/mobile/src/app/halls/[slug].tsx index 2eecacb0..a9c4bc8f 100644 --- a/mobile/src/app/halls/[slug].tsx +++ b/mobile/src/app/halls/[slug].tsx @@ -12,6 +12,8 @@ import { import { useFocusEffect, useLocalSearchParams } from "expo-router"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, SectionList, StyleSheet, Text, TextInput, View } from "react-native"; +import { Button, EmptyState } from "../../components/ui"; +import { colors, fonts, spacing, withOpacity } from "../../lib/theme"; import { SqliteFavoritesStorage } from "../../lib/favoritesStorage"; import { getPreferences } from "../../lib/preferences"; import { SqliteLogStorage } from "../../lib/sqliteStorage"; @@ -58,7 +60,7 @@ export default function HallMenuScreen() { if (!hall) return Unknown dining hall; if (error) return Failed to load menu: {error}; - if (!items) return ; + if (!items) return ; async function toggleDishFavorite(dishName: string) { const favorite: Favorite = { type: "dish", dishName }; @@ -91,7 +93,7 @@ export default function HallMenuScreen() { return ( {sections.length === 0 ? ( - No menu matches your filters at {hall.name} today. + ) : ( {item.nutrition.calories} cal toggleDishFavorite(item.dishName)} hitSlop={8}> - {favoriteDishKeys.has(favoriteKey({ type: "dish", dishName: item.dishName })) ? "★" : "☆"} + + {favoriteDishKeys.has(favoriteKey({ type: "dish", dishName: item.dishName })) ? "★" : "☆"} + )} /> )} - {logged && {logged}} + {logged && ( + + {logged} + + )} {selected && ( {selected.dishName} - - Log - - setSelected(null)}> - Cancel - + + )} @@ -128,20 +136,46 @@ export default function HallMenuScreen() { } const styles = StyleSheet.create({ - container: { flex: 1 }, - error: { padding: 16, color: "red" }, - empty: { padding: 16 }, - sectionHeader: { fontSize: 16, fontWeight: "700", backgroundColor: "#eee", padding: 8, textTransform: "capitalize" }, - row: { flexDirection: "row", alignItems: "center", borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#ccc" }, - rowMain: { flex: 1, flexDirection: "row", justifyContent: "space-between", padding: 12 }, - rowText: { fontSize: 15, flex: 1 }, - rowCalories: { fontSize: 13, color: "#666" }, - star: { fontSize: 20, color: "#e0a800", paddingHorizontal: 12 }, - loggedBanner: { backgroundColor: "#d4edda", padding: 8, textAlign: "center" }, - logBar: { flexDirection: "row", alignItems: "center", padding: 12, gap: 8, borderTopWidth: 1, borderColor: "#ccc", backgroundColor: "#fafafa" }, - logBarTitle: { flex: 1, fontWeight: "600" }, - servingsInput: { borderWidth: 1, borderColor: "#ccc", borderRadius: 6, padding: 6, width: 50, textAlign: "center" }, - logButton: { backgroundColor: "#208AEF", paddingVertical: 8, paddingHorizontal: 14, borderRadius: 6 }, - logButtonText: { color: "white", fontWeight: "600" }, - cancelButton: { padding: 8 }, + container: { flex: 1, backgroundColor: colors.cream100 }, + loading: { flex: 1, backgroundColor: colors.cream100 }, + error: { padding: spacing(4), color: "#b00020", fontFamily: fonts.body }, + sectionHeader: { + fontFamily: fonts.display, + fontSize: 14, + fontWeight: "700", + letterSpacing: 1, + textTransform: "uppercase", + backgroundColor: colors.cream100, + color: colors.maroon900, + paddingHorizontal: spacing(4), + paddingVertical: spacing(2), + }, + row: { flexDirection: "row", alignItems: "center", borderBottomWidth: StyleSheet.hairlineWidth, borderColor: withOpacity(colors.ink900, 15), backgroundColor: colors.paper50 }, + rowMain: { flex: 1, flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: spacing(3) }, + rowText: { fontSize: 15, flex: 1, fontFamily: fonts.body, color: colors.ink900 }, + rowCalories: { fontSize: 13, fontFamily: fonts.mono, color: withOpacity(colors.ink900, 60) }, + star: { fontSize: 20, color: withOpacity(colors.ink900, 30), paddingHorizontal: spacing(3) }, + starActive: { color: colors.gold500 }, + loggedBanner: { backgroundColor: colors.maroon900, padding: spacing(2) }, + loggedBannerText: { color: colors.paper50, textAlign: "center", fontFamily: fonts.body, fontSize: 13 }, + logBar: { + flexDirection: "row", + alignItems: "center", + padding: spacing(3), + gap: spacing(2), + borderTopWidth: 1, + borderColor: withOpacity(colors.ink900, 15), + backgroundColor: colors.paper50, + }, + logBarTitle: { flex: 1, fontFamily: fonts.body, fontWeight: "600", color: colors.ink900 }, + servingsInput: { + borderWidth: 1, + borderColor: withOpacity(colors.ink900, 25), + borderRadius: 2, + padding: spacing(1.5), + width: 50, + textAlign: "center", + fontFamily: fonts.mono, + color: colors.ink900, + }, }); diff --git a/mobile/src/app/index.tsx b/mobile/src/app/index.tsx index 971d0869..58398a64 100644 --- a/mobile/src/app/index.tsx +++ b/mobile/src/app/index.tsx @@ -1,34 +1,54 @@ -import { DINING_HALLS, favoriteKey, type Favorite } from "@udine/shared"; +import { computeDailyTotals, DINING_HALLS, favoriteKey, type Favorite, type LogEntry } from "@udine/shared"; import type { Session } from "@supabase/supabase-js"; -import { Link } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; -import { useFocusEffect } from "expo-router"; -import { Alert, FlatList, Pressable, StyleSheet, Text, View } from "react-native"; +import { Link, useFocusEffect } from "expo-router"; +import { useCallback, useState } from "react"; +import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Badge, Card, Stat } from "../components/ui"; +import { colors, fonts, spacing, withOpacity } from "../lib/theme"; +import { todayIso } from "../lib/date"; import { SqliteFavoritesStorage } from "../lib/favoritesStorage"; +import { SqliteLogStorage } from "../lib/sqliteStorage"; import { signInWithGoogle, signOut } from "../lib/auth"; import { supabase } from "../lib/supabase"; const favoritesStorage = new SqliteFavoritesStorage(); +const logStorage = new SqliteLogStorage(); -export default function HallListScreen() { +const QUICK_LINKS: { href: string; label: string }[] = [ + { href: "/filters", label: "Dietary filters" }, + { href: "/friends", label: "Friends" }, + { href: "/notifications", label: "Notifications" }, + { href: "/events", label: "Events" }, + { href: "/press", label: "Press" }, + { href: "/newsletter", label: "Newsletter" }, +]; + +export default function HomeScreen() { const [favoriteHallKeys, setFavoriteHallKeys] = useState>(new Set()); const [session, setSession] = useState(null); + const [entries, setEntries] = useState([]); + const date = todayIso(); const load = useCallback(() => { favoritesStorage.getFavorites().then((favs) => { setFavoriteHallKeys(new Set(favs.filter((f) => f.type === "location").map(favoriteKey))); }); - }, []); + logStorage.getEntriesForDate(date).then(setEntries); + }, [date]); useFocusEffect(load); - useEffect(() => { - supabase.auth.getSession().then(({ data }) => setSession(data.session)); - const { - data: { subscription }, - } = supabase.auth.onAuthStateChange((_event, newSession) => setSession(newSession)); - return () => subscription.unsubscribe(); - }, []); + useFocusEffect( + useCallback(() => { + supabase.auth.getSession().then(({ data }) => setSession(data.session)); + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, newSession) => setSession(newSession)); + return () => subscription.unsubscribe(); + }, []), + ); + + const totals = computeDailyTotals(date, entries); async function handleSignIn() { try { @@ -50,7 +70,7 @@ export default function HallListScreen() { } return ( - + {session ? ( <> @@ -65,57 +85,121 @@ export default function HallListScreen() { )} - hall.slug} - renderItem={({ item }) => ( - - - - {item.name} + + Today + + + + + + + + + + + + + + + + + + + + View full day → + + + + + Dining halls + + + + {DINING_HALLS.map((hall) => { + const isFavorite = favoriteHallKeys.has(favoriteKey({ type: "location", hallTid: hall.tid })); + return ( + + toggleHall(hall.tid)} hitSlop={8}> + {isFavorite ? "★" : "☆"} - - toggleHall(item.tid)} hitSlop={8}> - {favoriteHallKeys.has(favoriteKey({ type: "location", hallTid: item.tid })) ? "★" : "☆"} - - - )} - /> - - - - - - - - - - + + + {hall.name} + {isFavorite && Favorite} + + + + ); + })} - - ); -} -function NavButton({ href, label }: { href: string; label: string }) { - return ( - - - {label} - - + More + + + + + Rank dishes + + + + + Favorites + + + {QUICK_LINKS.map((link) => ( + + + {link.label} + + + ))} + + ); } const styles = StyleSheet.create({ - container: { flex: 1, padding: 16 }, - authRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, - authText: { color: "#555" }, - authLink: { color: "#208AEF", fontWeight: "600" }, - row: { flexDirection: "row", alignItems: "center", borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#ccc" }, - rowLink: { flex: 1, paddingVertical: 16 }, - rowText: { fontSize: 18 }, - star: { fontSize: 22, color: "#e0a800", paddingHorizontal: 8 }, - navGrid: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 16 }, - navButton: { flexGrow: 1, minWidth: "30%", padding: 12, backgroundColor: "#208AEF", borderRadius: 8, alignItems: "center" }, - navButtonText: { color: "white", fontWeight: "600" }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + authRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: spacing(3) }, + authText: { color: withOpacity(colors.ink900, 60), fontFamily: fonts.body, fontSize: 13 }, + authLink: { color: colors.maroon600, fontFamily: fonts.body, fontWeight: "600", fontSize: 13 }, + pageTitle: { + fontFamily: fonts.display, + fontSize: 28, + lineHeight: 32, + fontWeight: "700", + textTransform: "uppercase", + color: colors.maroon900, + }, + rule: { marginTop: spacing(2), height: 0, borderTopWidth: 4, borderBottomWidth: 1, borderColor: colors.gold500 }, + sectionTitle: { + marginTop: spacing(6), + fontFamily: fonts.display, + fontSize: 17, + fontWeight: "700", + letterSpacing: 1, + textTransform: "uppercase", + color: colors.maroon900, + }, + thinRule: { marginTop: spacing(1), height: 1, backgroundColor: withOpacity(colors.ink900, 25) }, + statsCard: { marginTop: spacing(4), padding: spacing(4) }, + statsGrid: { flexDirection: "row", flexWrap: "wrap", gap: spacing(4) }, + statCell: { minWidth: "40%", flexGrow: 1 }, + statsLink: { marginTop: spacing(3), color: colors.maroon600, fontFamily: fonts.body, fontWeight: "600", fontSize: 13 }, + hallList: { marginTop: spacing(3), gap: spacing(2) }, + hallRow: { flexDirection: "row", alignItems: "center", padding: spacing(3), gap: spacing(2) }, + hallLink: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: spacing(2) }, + hallName: { fontFamily: fonts.display, fontSize: 16, fontWeight: "700", textTransform: "uppercase", color: colors.maroon900 }, + star: { fontSize: 22, color: withOpacity(colors.ink900, 30) }, + starActive: { color: colors.gold500 }, + quickLinks: { marginTop: spacing(3), flexDirection: "row", flexWrap: "wrap", gap: spacing(2) }, + quickLink: { + borderWidth: 1, + borderColor: withOpacity(colors.maroon600, 45), + borderRadius: 2, + paddingVertical: spacing(1.5), + paddingHorizontal: spacing(3), + }, + quickLinkText: { color: colors.maroon600, fontFamily: fonts.body, fontWeight: "600", fontSize: 13 }, }); diff --git a/mobile/src/app/newsletter.tsx b/mobile/src/app/newsletter.tsx index d61b70a6..87c3cf49 100644 --- a/mobile/src/app/newsletter.tsx +++ b/mobile/src/app/newsletter.tsx @@ -1,6 +1,8 @@ import { fetchNewsletter, type NewsletterIssue } from "@udine/shared"; import { useEffect, useState } from "react"; -import { ActivityIndicator, FlatList, Linking, Pressable, StyleSheet, Text } from "react-native"; +import { ActivityIndicator, FlatList, Linking, Pressable, StyleSheet, Text, View } from "react-native"; +import { Card, EmptyState } from "../components/ui"; +import { colors, fonts, spacing } from "../lib/theme"; export default function NewsletterScreen() { const [issues, setIssues] = useState(null); @@ -11,27 +13,33 @@ export default function NewsletterScreen() { }, []); if (error) return Failed to load newsletter: {error}; - if (!issues) return ; + if (!issues) return ; return ( `${item.link}-${i}`} renderItem={({ item }) => ( - Linking.openURL(item.link)}> - {item.period} + Linking.openURL(item.link)}> + + {item.period} + )} - ListEmptyComponent={No newsletter issues right now.} + ItemSeparatorComponent={() => } + ListEmptyComponent={} /> ); } const styles = StyleSheet.create({ - container: { flex: 1 }, - row: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#ccc" }, - period: { fontSize: 16, fontWeight: "600" }, - error: { padding: 16, color: "red" }, - empty: { padding: 16, color: "#666" }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + loading: { flex: 1, backgroundColor: colors.cream100 }, + row: { padding: spacing(3) }, + period: { fontSize: 16, fontWeight: "700", fontFamily: fonts.display, color: colors.maroon900 }, + error: { padding: spacing(4), color: "#b00020", fontFamily: fonts.body }, + separator: { height: spacing(2) }, }); diff --git a/mobile/src/app/notifications.tsx b/mobile/src/app/notifications.tsx index d7500670..02cbff14 100644 --- a/mobile/src/app/notifications.tsx +++ b/mobile/src/app/notifications.tsx @@ -5,6 +5,8 @@ import * as Notifications from "expo-notifications"; import { useCallback, useEffect, useState } from "react"; import { useFocusEffect } from "expo-router"; import { Pressable, ScrollView, StyleSheet, Switch, Text, View } from "react-native"; +import { EmptyState } from "../components/ui"; +import { colors, fonts, spacing, withOpacity } from "../lib/theme"; import { SqliteFavoritesStorage } from "../lib/favoritesStorage"; import { supabase } from "../lib/supabase"; @@ -97,23 +99,27 @@ export default function NotificationsScreen() { if (!session) { return ( - - Sign in to enable favorited-food alerts. + + ); } return ( - + + Notifications + + Notify me when a favorited dish shows up on the menu - + Turning this on will request notification permission and register your device for push. This is always the notification feed either way. Sightings + {sightings.length === 0 ? ( - No favorited-food sightings yet. + ) : ( sightings.map((s) => ( markRead(s)} style={[styles.sightingRow, s.read_at ? styles.sightingRead : null]}> @@ -128,14 +134,25 @@ export default function NotificationsScreen() { } const styles = StyleSheet.create({ - container: { flex: 1, padding: 16 }, - toggleRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12 }, - toggleLabel: { flex: 1, fontSize: 16 }, - hint: { color: "#888", fontSize: 12, marginTop: 8 }, - sectionTitle: { fontSize: 18, fontWeight: "600", marginTop: 20, marginBottom: 8 }, - empty: { color: "#888" }, - sightingRow: { paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#eee" }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + pageTitle: { fontFamily: fonts.display, fontSize: 24, fontWeight: "700", textTransform: "uppercase", color: colors.maroon900 }, + rule: { marginTop: spacing(2), marginBottom: spacing(4), height: 0, borderTopWidth: 4, borderBottomWidth: 1, borderColor: colors.gold500 }, + toggleRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: spacing(3) }, + toggleLabel: { flex: 1, fontSize: 15, fontFamily: fonts.body, color: colors.ink900 }, + hint: { color: withOpacity(colors.ink900, 55), fontSize: 12, fontFamily: fonts.body, marginTop: spacing(2) }, + sectionTitle: { + marginTop: spacing(6), + fontFamily: fonts.display, + fontSize: 15, + fontWeight: "700", + letterSpacing: 1, + textTransform: "uppercase", + color: colors.maroon900, + }, + thinRule: { marginTop: spacing(1), marginBottom: spacing(3), height: 1, backgroundColor: withOpacity(colors.ink900, 25) }, + sightingRow: { paddingVertical: spacing(2), borderBottomWidth: StyleSheet.hairlineWidth, borderColor: withOpacity(colors.ink900, 15) }, sightingRead: { opacity: 0.6 }, - sightingText: { fontSize: 16 }, - sightingDish: { fontWeight: "600" }, + sightingText: { fontSize: 15, fontFamily: fonts.body, color: colors.ink900 }, + sightingDish: { fontWeight: "700", color: colors.maroon900 }, }); diff --git a/mobile/src/app/press.tsx b/mobile/src/app/press.tsx index 94975e75..434a64ca 100644 --- a/mobile/src/app/press.tsx +++ b/mobile/src/app/press.tsx @@ -1,6 +1,8 @@ import { fetchPressReleases, type PressRelease } from "@udine/shared"; import { useEffect, useState } from "react"; -import { ActivityIndicator, FlatList, Image, Linking, Pressable, StyleSheet, Text } from "react-native"; +import { ActivityIndicator, FlatList, Image, Linking, Pressable, StyleSheet, Text, View } from "react-native"; +import { Card, EmptyState } from "../components/ui"; +import { colors, fonts, spacing, withOpacity } from "../lib/theme"; export default function PressScreen() { const [items, setItems] = useState(null); @@ -11,31 +13,37 @@ export default function PressScreen() { }, []); if (error) return Failed to load press releases: {error}; - if (!items) return ; + if (!items) return ; return ( `${item.url}-${i}`} renderItem={({ item }) => ( - Linking.openURL(item.url)}> - {!!item.image && } - {item.title} - {item.date} + Linking.openURL(item.url)}> + + {!!item.image && } + {item.title} + {item.date} + )} - ListEmptyComponent={No press releases right now.} + ItemSeparatorComponent={() => } + ListEmptyComponent={} /> ); } const styles = StyleSheet.create({ - container: { flex: 1 }, - row: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#ccc" }, - image: { width: "100%", height: 140, borderRadius: 6, marginBottom: 8, backgroundColor: "#eee" }, - title: { fontSize: 16, fontWeight: "600" }, - date: { fontSize: 13, color: "#666", marginTop: 4 }, - error: { padding: 16, color: "red" }, - empty: { padding: 16, color: "#666" }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + loading: { flex: 1, backgroundColor: colors.cream100 }, + row: { padding: spacing(3) }, + image: { width: "100%", height: 140, borderRadius: 6, marginBottom: spacing(2), backgroundColor: withOpacity(colors.ink900, 8) }, + title: { fontSize: 16, fontWeight: "700", fontFamily: fonts.display, color: colors.maroon900 }, + date: { fontSize: 13, fontFamily: fonts.mono, color: withOpacity(colors.ink900, 55), marginTop: spacing(1) }, + error: { padding: spacing(4), color: "#b00020", fontFamily: fonts.body }, + separator: { height: spacing(2) }, }); diff --git a/mobile/src/app/rank.tsx b/mobile/src/app/rank.tsx index 7a651735..a695141d 100644 --- a/mobile/src/app/rank.tsx +++ b/mobile/src/app/rank.tsx @@ -12,7 +12,9 @@ import { } from "@udine/shared"; import { useFocusEffect } from "expo-router"; import { useCallback, useRef, useState } from "react"; -import { Pressable, StyleSheet, Text, View } from "react-native"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { Button, Card } from "../components/ui"; +import { colors, fonts, spacing, withOpacity } from "../lib/theme"; import { SqliteLogStorage } from "../lib/sqliteStorage"; import { SqliteRankingStorage } from "../lib/rankingStorage"; import { supabase } from "../lib/supabase"; @@ -89,33 +91,31 @@ export default function RankScreen() { const hallRanking = rankDiningHalls(rankedDishes); return ( - + + Rank Dishes + Compare dishes you've actually logged — ranking is built from what you've eaten, not the full menu. {loggedDishes.length < 2 ? ( Log a couple of meals first, then come back here to rank them. ) : pair ? ( - + Which did you like more? - choose(pair[0], pair[1])}> - - {pair[0].dishName} ({hallName(pair[0].hallTid)}) - - - choose(pair[1], pair[0])}> - - {pair[1].dishName} ({hallName(pair[1].hallTid)}) - - - - Skip - - + + + + ) : null} Your ranking {rankedDishes.length === 0 ? ( - No comparisons yet. + No comparisons yet. ) : ( rankDishes(rankedDishes).map((dish, i) => ( @@ -127,7 +127,7 @@ export default function RankScreen() { Favorite Foods Your favorite dishes by name, regardless of which hall serves them. {rankedFoods.length === 0 ? ( - No comparisons yet. + No comparisons yet. ) : ( rankFoods(rankedFoods).map((food, i) => ( @@ -143,20 +143,32 @@ export default function RankScreen() { ))} {hallRanking.unranked.map((hall) => ( - + {hallName(hall.hallTid)} — not enough data yet ))} - + ); } const styles = StyleSheet.create({ - container: { flex: 1, padding: 16 }, - hint: { color: "#555", marginBottom: 12 }, - heading: { fontSize: 18, fontWeight: "600", marginTop: 20, marginBottom: 8 }, - choiceButton: { backgroundColor: "#208AEF", borderRadius: 8, padding: 14, marginBottom: 8 }, - choiceButtonText: { color: "white", fontWeight: "600", textAlign: "center" }, - skip: { color: "#888", textAlign: "center", marginTop: 4 }, - rankRow: { paddingVertical: 4 }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + pageTitle: { fontFamily: fonts.display, fontSize: 24, fontWeight: "700", textTransform: "uppercase", color: colors.maroon900 }, + rule: { marginTop: spacing(2), marginBottom: spacing(3), height: 0, borderTopWidth: 4, borderBottomWidth: 1, borderColor: colors.gold500 }, + hint: { fontFamily: fonts.body, color: withOpacity(colors.ink900, 65), marginBottom: spacing(3) }, + heading: { + fontFamily: fonts.display, + fontSize: 15, + fontWeight: "700", + letterSpacing: 1, + textTransform: "uppercase", + color: colors.maroon900, + marginTop: spacing(5), + marginBottom: spacing(2), + }, + compareCard: { padding: spacing(4), gap: spacing(2) }, + choiceButton: { marginBottom: spacing(0) }, + rankRow: { fontFamily: fonts.body, fontSize: 14, color: colors.ink900, paddingVertical: spacing(1) }, + rankRowMuted: { fontFamily: fonts.body, fontSize: 14, color: withOpacity(colors.ink900, 50), paddingVertical: spacing(1) }, }); diff --git a/mobile/src/app/today.tsx b/mobile/src/app/today.tsx index 6f5da2a1..6d2ed8b3 100644 --- a/mobile/src/app/today.tsx +++ b/mobile/src/app/today.tsx @@ -3,7 +3,9 @@ import * as FileSystem from "expo-file-system/legacy"; import * as Sharing from "expo-sharing"; import { useCallback, useState } from "react"; import { useFocusEffect } from "expo-router"; -import { FlatList, Pressable, StyleSheet, Text, View } from "react-native"; +import { FlatList, StyleSheet, Text, View } from "react-native"; +import { Button, Card, EmptyState, Stat } from "../components/ui"; +import { colors, fonts, spacing, withOpacity } from "../lib/theme"; import { todayIso } from "../lib/date"; import { SqliteLogStorage } from "../lib/sqliteStorage"; @@ -37,50 +39,97 @@ export default function TodayScreen() { } return ( - - - {date} - {Math.round(totals.calories)} cal - - {totals.proteinG.toFixed(1)}g protein · {totals.totalCarbG.toFixed(1)}g carbs · {totals.totalFatG.toFixed(1)}g fat - - - e.id} - renderItem={({ item }) => ( - - - {item.source.type === "umass-menu" ? item.source.dishName : item.source.productName} × {item.servings} - - remove(item.id)}> - Remove - + e.id} + ListHeaderComponent={ + + {date} + + + + + + + + + + + + + + + + + + + + Logged today + + + } + renderItem={({ item }) => ( + + + {item.source.type === "umass-menu" ? item.source.dishName : item.source.productName} × {item.servings} + + + + )} + ItemSeparatorComponent={() => } + ListEmptyComponent={ + + } + ListFooterComponent={ + + Export your data + + + + - )} - ListEmptyComponent={Nothing logged today.} - /> - - exportData("json")}> - Export JSON - - exportData("csv")}> - Export CSV - - - + + } + /> ); } const styles = StyleSheet.create({ - container: { flex: 1 }, - totals: { padding: 16, backgroundColor: "#eef6ff" }, - totalsTitle: { fontSize: 16, fontWeight: "700", marginBottom: 4 }, - row: { flexDirection: "row", justifyContent: "space-between", padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#ccc" }, - rowText: { flex: 1 }, - remove: { color: "#c00" }, - empty: { padding: 16, color: "#666" }, - exportRow: { flexDirection: "row", gap: 8, padding: 12 }, - exportButton: { flex: 1, backgroundColor: "#208AEF", padding: 12, borderRadius: 8, alignItems: "center" }, - exportButtonText: { color: "white", fontWeight: "600" }, + screen: { flex: 1, backgroundColor: colors.cream100 }, + container: { padding: spacing(4), paddingBottom: spacing(10) }, + pageTitle: { + fontFamily: fonts.display, + fontSize: 24, + fontWeight: "700", + textTransform: "uppercase", + color: colors.maroon900, + }, + rule: { marginTop: spacing(2), marginBottom: spacing(4), height: 0, borderTopWidth: 4, borderBottomWidth: 1, borderColor: colors.gold500 }, + statsCard: { padding: spacing(4) }, + statsGrid: { flexDirection: "row", flexWrap: "wrap", gap: spacing(4) }, + statCell: { minWidth: "40%", flexGrow: 1 }, + sectionTitle: { + marginTop: spacing(6), + fontFamily: fonts.display, + fontSize: 15, + fontWeight: "700", + letterSpacing: 1, + textTransform: "uppercase", + color: colors.maroon900, + }, + thinRule: { marginTop: spacing(1), marginBottom: spacing(3), height: 1, backgroundColor: withOpacity(colors.ink900, 25) }, + row: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: spacing(3) }, + rowText: { flex: 1, fontFamily: fonts.body, fontSize: 15, color: colors.ink900 }, + separator: { height: spacing(2) }, + exportSection: { marginTop: spacing(4) }, + exportRow: { flexDirection: "row", gap: spacing(2) }, + exportButton: { flex: 1 }, }); diff --git a/mobile/src/components/ui/Badge.test.tsx b/mobile/src/components/ui/Badge.test.tsx new file mode 100644 index 00000000..fe101505 --- /dev/null +++ b/mobile/src/components/ui/Badge.test.tsx @@ -0,0 +1,13 @@ +import renderer, { act } from "react-test-renderer"; +import { Text } from "react-native"; +import { Badge } from "./Badge"; + +describe("Badge", () => { + it("renders its label text", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create(Favorite); + }); + expect(root.root.findByType(Text).props.children).toBe("Favorite"); + }); +}); diff --git a/mobile/src/components/ui/Badge.tsx b/mobile/src/components/ui/Badge.tsx new file mode 100644 index 00000000..91beaff1 --- /dev/null +++ b/mobile/src/components/ui/Badge.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { colors, fonts, radii, spacing, withOpacity } from "../../lib/theme"; + +/** Small uppercase chip for metadata (allergens, status, hall names). Mirrors .badge. */ +export function Badge({ children, style }: { children: ReactNode; style?: StyleProp }) { + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + badge: { + alignSelf: "flex-start", + borderRadius: radii.sm, + backgroundColor: withOpacity(colors.ink900, 8), + paddingVertical: 1, + paddingHorizontal: spacing(1.5), + }, + text: { + fontFamily: fonts.body, + fontSize: 11, + fontWeight: "600", + letterSpacing: 0.6, + textTransform: "uppercase", + color: withOpacity(colors.ink900, 70), + }, +}); diff --git a/mobile/src/components/ui/Button.test.tsx b/mobile/src/components/ui/Button.test.tsx new file mode 100644 index 00000000..0256e4c0 --- /dev/null +++ b/mobile/src/components/ui/Button.test.tsx @@ -0,0 +1,64 @@ +import renderer, { act } from "react-test-renderer"; +import { Text } from "react-native"; +import { Button } from "./Button"; +import { buttonColors } from "../../lib/theme"; + +function flatten(style: unknown): Record { + return Object.assign({}, ...(Array.isArray(style) ? style : [style]).filter(Boolean)); +} + +describe("Button", () => { + it("renders its label", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create(); + }); + expect(root.root.findByType(Text).props.children).toBe("Log"); + }); + + it("applies the primary variant's fill and text color", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create(); + }); + const expected = buttonColors("primary"); + // Assert against the Text color directly — the most stable signal across RN's Pressable internals. + const textStyle = flatten(root.root.findByType(Text).props.style); + expect(textStyle.color).toBe(expected.color); + }); + + it("applies the ghost variant's muted text color, distinct from primary", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create(); + }); + const textStyle = flatten(root.root.findByType(Text).props.style); + expect(textStyle.color).toBe(buttonColors("ghost").color); + expect(textStyle.color).not.toBe(buttonColors("primary").color); + }); + + it("defaults to the primary variant when none is given", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create(); + }); + const textStyle = flatten(root.root.findByType(Text).props.style); + expect(textStyle.color).toBe(buttonColors("primary").color); + }); + + it("fires onPress when tapped and not disabled", () => { + const onPress = jest.fn(); + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create( + , + ); + }); + act(() => { + root.root.findByProps({ accessibilityRole: "button" }).props.onPress(); + }); + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/mobile/src/components/ui/Button.tsx b/mobile/src/components/ui/Button.tsx new file mode 100644 index 00000000..5022eee0 --- /dev/null +++ b/mobile/src/components/ui/Button.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { Pressable, StyleSheet, Text, type PressableProps, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; +import { buttonColors, fonts, radii, spacing, type ButtonVariant } from "../../lib/theme"; + +type Props = Omit & { + variant?: ButtonVariant; + size?: "default" | "sm"; + style?: StyleProp; + textStyle?: StyleProp; + children: ReactNode; +}; + +/** Base button; pair with exactly one variant. Mirrors .btn + .btn-primary/-secondary/-ghost. */ +export function Button({ variant = "primary", size = "default", style, textStyle, disabled, children, ...props }: Props) { + const c = buttonColors(variant); + return ( + + {typeof children === "string" ? ( + {children} + ) : ( + children + )} + + ); +} + +const styles = StyleSheet.create({ + base: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing(1.5), + borderWidth: 1, + borderRadius: radii.sm, + paddingVertical: spacing(2.5), + paddingHorizontal: spacing(3.5), + }, + sm: { + paddingVertical: spacing(1), + paddingHorizontal: spacing(2), + }, + disabled: { + opacity: 0.45, + }, + text: { + fontFamily: fonts.body, + fontSize: 14, + fontWeight: "600", + }, + textSm: { + fontSize: 13, + }, +}); diff --git a/mobile/src/components/ui/Card.test.tsx b/mobile/src/components/ui/Card.test.tsx new file mode 100644 index 00000000..874380f2 --- /dev/null +++ b/mobile/src/components/ui/Card.test.tsx @@ -0,0 +1,35 @@ +import renderer, { act } from "react-test-renderer"; +import { Text, View } from "react-native"; +import { Card } from "./Card"; +import { colors } from "../../lib/theme"; + +function flatten(style: unknown): Record { + return Object.assign({}, ...(Array.isArray(style) ? style : [style]).filter(Boolean)); +} + +describe("Card", () => { + it("renders its children", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create( + + Franklin + , + ); + }); + expect(root.root.findByType(Text).props.children).toBe("Franklin"); + }); + + it("uses the paper-50 surface color, mirroring .card", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create( + + content + , + ); + }); + const style = flatten(root.root.findByType(View).props.style); + expect(style.backgroundColor).toBe(colors.paper50); + }); +}); diff --git a/mobile/src/components/ui/Card.tsx b/mobile/src/components/ui/Card.tsx new file mode 100644 index 00000000..69208832 --- /dev/null +++ b/mobile/src/components/ui/Card.tsx @@ -0,0 +1,16 @@ +import { StyleSheet, View, type ViewProps } from "react-native"; +import { colors, radii, withOpacity } from "../../lib/theme"; + +/** Paper surface on the cream ground — the default container for a list/panel. Mirrors .card. */ +export function Card({ style, ...props }: ViewProps) { + return ; +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.paper50, + borderWidth: 1, + borderColor: withOpacity(colors.ink900, 12), + borderRadius: radii.md, + }, +}); diff --git a/mobile/src/components/ui/EmptyState.test.tsx b/mobile/src/components/ui/EmptyState.test.tsx new file mode 100644 index 00000000..e45ef10d --- /dev/null +++ b/mobile/src/components/ui/EmptyState.test.tsx @@ -0,0 +1,23 @@ +import renderer, { act } from "react-test-renderer"; +import { Text } from "react-native"; +import { EmptyState } from "./EmptyState"; + +describe("EmptyState", () => { + it("renders the title and an optional message", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create(); + }); + const texts = root.root.findAllByType(Text).map((n) => n.props.children); + expect(texts).toContain("No favorites yet"); + expect(texts).toContain("Star a dish to add one."); + }); + + it("renders an optional action node below the message", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create(Find something to eat} />); + }); + expect(root.root.findAllByType(Text).map((n) => n.props.children)).toContain("Find something to eat"); + }); +}); diff --git a/mobile/src/components/ui/EmptyState.tsx b/mobile/src/components/ui/EmptyState.tsx new file mode 100644 index 00000000..499de042 --- /dev/null +++ b/mobile/src/components/ui/EmptyState.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { colors, fonts, radii, spacing, withOpacity } from "../../lib/theme"; + +/** Dashed-outline panel for "nothing here yet" + what to do about it. Mirrors .empty-state. */ +export function EmptyState({ title, message, action }: { title: string; message?: string; action?: ReactNode }) { + return ( + + {title} + {message ? {message} : null} + {action} + + ); +} + +const styles = StyleSheet.create({ + container: { + borderWidth: 1, + borderStyle: "dashed", + borderColor: withOpacity(colors.ink900, 25), + borderRadius: radii.md, + padding: spacing(6), + alignItems: "center", + gap: spacing(2), + }, + title: { + fontFamily: fonts.display, + fontSize: 17, + fontWeight: "700", + textTransform: "uppercase", + textAlign: "center", + color: colors.maroon900, + }, + message: { + fontFamily: fonts.body, + fontSize: 14, + textAlign: "center", + color: withOpacity(colors.ink900, 70), + }, +}); diff --git a/mobile/src/components/ui/Stat.test.tsx b/mobile/src/components/ui/Stat.test.tsx new file mode 100644 index 00000000..ee7c57fd --- /dev/null +++ b/mobile/src/components/ui/Stat.test.tsx @@ -0,0 +1,28 @@ +import renderer, { act } from "react-test-renderer"; +import { Text } from "react-native"; +import { Stat } from "./Stat"; + +describe("Stat", () => { + it("renders the label and value", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create(); + }); + const texts = root.root.findAllByType(Text).map((n) => n.props.children); + expect(texts).toContain("Calories"); + expect(texts).toContain("1850"); + }); + + it("renders an optional caption, and omits it when not given", () => { + let root!: renderer.ReactTestRenderer; + act(() => { + root = renderer.create(); + }); + expect(root.root.findAllByType(Text).map((n) => n.props.children)).toContain("42% of calories"); + + act(() => { + root = renderer.create(); + }); + expect(root.root.findAllByType(Text)).toHaveLength(2); + }); +}); diff --git a/mobile/src/components/ui/Stat.tsx b/mobile/src/components/ui/Stat.tsx new file mode 100644 index 00000000..4a22dc64 --- /dev/null +++ b/mobile/src/components/ui/Stat.tsx @@ -0,0 +1,36 @@ +import { StyleSheet, Text, View } from "react-native"; +import { colors, fonts, withOpacity } from "../../lib/theme"; + +/** Label-over-number block, mono numerals (nutrition-label register). Mirrors .stat. */ +export function Stat({ label, value, caption }: { label: string; value: string; caption?: string }) { + return ( + + {label} + {value} + {caption ? {caption} : null} + + ); +} + +const styles = StyleSheet.create({ + label: { + fontFamily: fonts.body, + fontSize: 11, + fontWeight: "600", + letterSpacing: 1, + textTransform: "uppercase", + color: withOpacity(colors.ink900, 60), + }, + value: { + fontFamily: fonts.mono, + fontSize: 28, + fontVariant: ["tabular-nums"], + lineHeight: 32, + color: colors.maroon900, + }, + caption: { + marginTop: 2, + fontSize: 12, + color: withOpacity(colors.ink900, 50), + }, +}); diff --git a/mobile/src/components/ui/index.ts b/mobile/src/components/ui/index.ts new file mode 100644 index 00000000..cd8afde3 --- /dev/null +++ b/mobile/src/components/ui/index.ts @@ -0,0 +1,5 @@ +export { Badge } from "./Badge"; +export { Button } from "./Button"; +export { Card } from "./Card"; +export { EmptyState } from "./EmptyState"; +export { Stat } from "./Stat"; diff --git a/mobile/src/lib/theme.test.ts b/mobile/src/lib/theme.test.ts new file mode 100644 index 00000000..d4085379 --- /dev/null +++ b/mobile/src/lib/theme.test.ts @@ -0,0 +1,36 @@ +import { buttonColors, colors, withOpacity } from "./theme"; + +describe("withOpacity", () => { + it("appends a hex alpha suffix for the given percentage", () => { + // 100% -> ff, 0% -> 00, 50% -> 80 (matches web's color-mix(in srgb, X 50%, transparent) look) + expect(withOpacity("#241a14", 100)).toBe("#241a14ff"); + expect(withOpacity("#241a14", 0)).toBe("#241a1400"); + }); + + it("clamps out-of-range percentages instead of producing an invalid hex", () => { + expect(withOpacity("#241a14", 150)).toBe(withOpacity("#241a14", 100)); + expect(withOpacity("#241a14", -20)).toBe(withOpacity("#241a14", 0)); + }); +}); + +describe("buttonColors", () => { + it("maps primary to a maroon fill with paper text, mirroring .btn-primary", () => { + const c = buttonColors("primary"); + expect(c.backgroundColor).toBe(colors.maroon600); + expect(c.color).toBe(colors.paper50); + }); + + it("maps secondary to an outlined maroon look, mirroring .btn-secondary", () => { + const c = buttonColors("secondary"); + expect(c.backgroundColor).toBe("transparent"); + expect(c.color).toBe(colors.maroon600); + expect(c.borderColor).not.toBe("transparent"); + }); + + it("maps ghost to a borderless, muted-ink look, mirroring .btn-ghost", () => { + const c = buttonColors("ghost"); + expect(c.backgroundColor).toBe("transparent"); + expect(c.borderColor).toBe("transparent"); + expect(c.color).not.toBe(colors.maroon600); + }); +}); diff --git a/mobile/src/lib/theme.ts b/mobile/src/lib/theme.ts new file mode 100644 index 00000000..25b98d34 --- /dev/null +++ b/mobile/src/lib/theme.ts @@ -0,0 +1,73 @@ +/** + * Mobile design tokens — ports web/src/app.css's @theme block to React Native (StyleSheet only, + * no component library, no new dependencies). Same names, same roles as web: + * + * maroon900/maroon600 brand + primary accent + * gold500 signature accent, used sparingly + * cream100/paper50 page ground / card surface + * ink900 body text (muted via withOpacity, mirroring web's color-mix opacity trick) + * + * Web loads Oswald (display) + Libre Franklin (body) as webfonts. Mobile has no bundled font + * assets (adding them would be a new dependency/asset pipeline this ticket doesn't call for), so + * `fonts` mirrors the *role* — display/body/mono — using each platform's built-in font stack + * instead of the exact typeface. Consuming styles still apply the uppercase+tracking+weight that + * gives headings their condensed-signage feel. + */ +import { Platform } from "react-native"; + +export const colors = { + maroon900: "#3b0a0f", + maroon600: "#7c2430", + gold500: "#c99a2e", + cream100: "#f3ead8", + paper50: "#fbf7ef", + ink900: "#241a14", +} as const; + +/** + * `hex` at `opacityPct`, as a hex-with-alpha string. Mirrors web's + * `color-mix(in srgb, var(--color-x) N%, transparent)` opacity trick, which app.css leans on for + * every muted-text/border tone instead of a 7th named color token. + */ +export function withOpacity(hex: string, opacityPct: number): string { + const clamped = Math.max(0, Math.min(100, opacityPct)); + const alpha = Math.round((clamped / 100) * 255) + .toString(16) + .padStart(2, "0"); + return `${hex}${alpha}`; +} + +export const fonts = { + display: Platform.select({ ios: "System", android: "sans-serif-condensed", default: "System" }), + body: Platform.select({ ios: "System", android: "sans-serif", default: "System" }), + mono: Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" }), +} as const; + +export const radii = { + sm: 2, + md: 6, + pill: 999, +} as const; + +/** Tailwind's default 4px spacing step, used as-is on web — same step here. */ +export function spacing(steps: number): number { + return steps * 4; +} + +export type ButtonVariant = "primary" | "secondary" | "ghost"; + +/** Mirrors .btn-primary / .btn-secondary / .btn-ghost in app.css's component layer. */ +export function buttonColors(variant: ButtonVariant): { + backgroundColor: string; + color: string; + borderColor: string; +} { + switch (variant) { + case "primary": + return { backgroundColor: colors.maroon600, color: colors.paper50, borderColor: "transparent" }; + case "secondary": + return { backgroundColor: "transparent", color: colors.maroon600, borderColor: withOpacity(colors.maroon600, 45) }; + case "ghost": + return { backgroundColor: "transparent", color: withOpacity(colors.ink900, 65), borderColor: "transparent" }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf165c22..1f5cc833 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,15 @@ importers: '@types/react': specifier: ~19.2.2 version: 19.2.18 + '@types/react-test-renderer': + specifier: 19.1.0 + version: 19.1.0 jest-expo: specifier: ~57.0.4 version: 57.0.4(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(expo@57.0.14)(jest@29.7.0(@types/node@22.20.1))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.87.0(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) + react-test-renderer: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) typescript: specifier: ~6.0.3 version: 6.0.3 From 2bad872892c07b1ae81e137d12e66af168b0b357 Mon Sep 17 00:00:00 2001 From: TSVRN9 <66744142+TSVRN9@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:15:28 -0400 Subject: [PATCH 2/2] mobile: pin palette hex values in theme tests (review nit on #71) --- mobile/src/lib/theme.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/mobile/src/lib/theme.test.ts b/mobile/src/lib/theme.test.ts index d4085379..a2178282 100644 --- a/mobile/src/lib/theme.test.ts +++ b/mobile/src/lib/theme.test.ts @@ -1,5 +1,16 @@ import { buttonColors, colors, withOpacity } from "./theme"; +describe("colors", () => { + it("mirrors web/src/app.css's @theme palette hex-for-hex", () => { + expect(colors.maroon900).toBe("#3b0a0f"); + expect(colors.maroon600).toBe("#7c2430"); + expect(colors.gold500).toBe("#c99a2e"); + expect(colors.cream100).toBe("#f3ead8"); + expect(colors.paper50).toBe("#fbf7ef"); + expect(colors.ink900).toBe("#241a14"); + }); +}); + describe("withOpacity", () => { it("appends a hex alpha suffix for the given percentage", () => { // 100% -> ff, 0% -> 00, 50% -> 80 (matches web's color-mix(in srgb, X 50%, transparent) look)