Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 9 additions & 1 deletion mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Stack } from "expo-router";
import { colors, fonts } from "../lib/theme";

export default function RootLayout() {
return (
<Stack>
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.maroon900 },
headerTintColor: colors.paper50,
headerTitleStyle: { fontFamily: fonts.display, fontWeight: "700" },
contentStyle: { backgroundColor: colors.cream100 },
}}
>
<Stack.Screen name="index" options={{ title: "UDine" }} />
<Stack.Screen name="redirect" options={{ headerShown: false, animation: "none" }} />
<Stack.Screen name="halls/[slug]" options={{ title: "Menu" }} />
Expand Down
40 changes: 24 additions & 16 deletions mobile/src/app/events.tsx
Original file line number Diff line number Diff line change
@@ -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<DiningEvent[] | null>(null);
Expand All @@ -11,7 +13,7 @@ export default function EventsScreen() {
}, []);

if (error) return <Text style={styles.error}>Failed to load events: {error}</Text>;
if (!items) return <ActivityIndicator style={styles.container} />;
if (!items) return <ActivityIndicator style={styles.loading} color={colors.maroon600} />;

function open(item: DiningEvent) {
const url = item.externalLink || item.pdfLink;
Expand All @@ -20,28 +22,34 @@ export default function EventsScreen() {

return (
<FlatList
style={styles.container}
style={styles.screen}
contentContainerStyle={styles.container}
data={items}
keyExtractor={(item, i) => `${item.title}-${i}`}
renderItem={({ item }) => (
<Pressable style={styles.row} onPress={() => open(item)}>
{!!item.featuredImage && <Image source={{ uri: item.featuredImage }} style={styles.image} />}
<Text style={styles.title}>
{item.isFeatured ? "★ " : ""}
{item.title}
</Text>
<Pressable onPress={() => open(item)}>
<Card style={styles.row}>
{!!item.featuredImage && <Image source={{ uri: item.featuredImage }} style={styles.image} />}
<Text style={styles.title}>
{item.isFeatured ? "★ " : ""}
{item.title}
</Text>
</Card>
</Pressable>
)}
ListEmptyComponent={<Text style={styles.empty}>No events right now.</Text>}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={<EmptyState title="No events" message="No events right now." />}
/>
);
}

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) },
});
32 changes: 22 additions & 10 deletions mobile/src/app/favorites.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -17,24 +19,34 @@ export default function FavoritesScreen() {

return (
<FlatList
style={styles.container}
style={styles.screen}
contentContainerStyle={styles.container}
data={favorites}
keyExtractor={(f, i) => `${f.type}-${i}`}
ListHeaderComponent={
<View>
<Text style={styles.pageTitle}>Favorites</Text>
<View style={styles.rule} />
</View>
}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.badge}>{item.type === "dish" ? "Dish" : "Hall"}</Text>
<Card style={styles.row}>
<Badge>{item.type === "dish" ? "Dish" : "Hall"}</Badge>
<Text style={styles.rowText}>{item.type === "dish" ? item.dishName : DINING_HALLS.find((h) => h.tid === item.hallTid)?.name ?? item.hallTid}</Text>
</View>
</Card>
)}
ListEmptyComponent={<Text style={styles.empty}>No favorites yet — star a dish or dining hall to add one.</Text>}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={<EmptyState title="No favorites yet" message="Star a dish or dining hall to add one." />}
/>
);
}

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) },
});
43 changes: 31 additions & 12 deletions mobile/src/app/filters.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -33,40 +35,57 @@ export default function FiltersScreen() {
setPreferences(updated);
}

if (!allergens) return <ActivityIndicator style={styles.container} />;
if (!allergens) return <ActivityIndicator style={styles.loading} color={colors.maroon600} />;

return (
<ScrollView style={styles.container}>
<ScrollView style={styles.screen} contentContainerStyle={styles.container}>
<Text style={styles.pageTitle}>Dietary Filters</Text>
<View style={styles.rule} />

<Text style={styles.sectionTitle}>Avoid allergens</Text>
<View style={styles.thinRule} />
<View style={styles.chipRow}>
{allergens.map((a) => (
<Pressable key={a} style={[styles.chip, prefs.allergensToAvoid.includes(a) && styles.chipActive]} onPress={() => toggleAllergen(a)}>
<Text style={[styles.chipText, prefs.allergensToAvoid.includes(a) && styles.chipTextActive]}>{a}</Text>
</Pressable>
))}
{allergens.length === 0 && <Text style={styles.empty}>No allergen data found in today's menus.</Text>}
</View>
{allergens.length === 0 && <EmptyState title="No allergen data" message="No allergen data found in today's menus." />}

<Text style={styles.sectionTitle}>Require diet tags</Text>
<View style={styles.thinRule} />
<View style={styles.chipRow}>
{dietTags.map((t) => (
<Pressable key={t} style={[styles.chip, prefs.requiredDietTags.includes(t) && styles.chipActive]} onPress={() => toggleDietTag(t)}>
<Text style={[styles.chipText, prefs.requiredDietTags.includes(t) && styles.chipTextActive]}>{t}</Text>
</Pressable>
))}
{dietTags.length === 0 && <Text style={styles.empty}>No diet-tag data found in today's menus.</Text>}
</View>
{dietTags.length === 0 && <EmptyState title="No diet-tag data" message="No diet-tag data found in today's menus." />}
</ScrollView>
);
}

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 },
});
Loading
Loading