A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

should've made this a seperate branch ey

+2683 -54
+3
.github/workflows/ci-mobile.yml
··· 31 31 - name: Install dependencies 32 32 run: pnpm install --frozen-lockfile 33 33 34 + - name: Lint 35 + run: pnpm --filter mobile run lint 36 + 34 37 - name: Typecheck 35 38 run: pnpm --filter mobile exec tsc --noEmit
+44
apps/mobile/app/(tabs)/_layout.tsx
··· 1 + import { Tabs } from "expo-router"; 2 + import { Home, Search, Library } from "lucide-react-native"; 3 + import { colors } from "@/constants/theme"; 4 + 5 + export default function TabLayout() { 6 + return ( 7 + <Tabs 8 + screenOptions={{ 9 + tabBarActiveTintColor: colors.primary, 10 + tabBarInactiveTintColor: colors.textMuted, 11 + tabBarStyle: { 12 + backgroundColor: colors.card, 13 + borderTopColor: colors.border, 14 + }, 15 + headerStyle: { 16 + backgroundColor: colors.background, 17 + }, 18 + headerTintColor: colors.text, 19 + }} 20 + > 21 + <Tabs.Screen 22 + name="index" 23 + options={{ 24 + title: "Home", 25 + tabBarIcon: ({ color, size }) => <Home size={size} color={color} />, 26 + }} 27 + /> 28 + <Tabs.Screen 29 + name="search" 30 + options={{ 31 + title: "Search", 32 + tabBarIcon: ({ color, size }) => <Search size={size} color={color} />, 33 + }} 34 + /> 35 + <Tabs.Screen 36 + name="shelf" 37 + options={{ 38 + title: "Shelf", 39 + tabBarIcon: ({ color, size }) => <Library size={size} color={color} />, 40 + }} 41 + /> 42 + </Tabs> 43 + ); 44 + }
+125
apps/mobile/app/(tabs)/index.tsx
··· 1 + import { router } from "expo-router"; 2 + import { Film, Search, Shield, Share2 } from "lucide-react-native"; 3 + import { ScrollView, StyleSheet, Text, View } from "react-native"; 4 + import { SafeAreaView } from "react-native-safe-area-context"; 5 + import { Button } from "@/components/ui/Button"; 6 + import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 7 + import { colors, spacing, borderRadius } from "@/constants/theme"; 8 + 9 + const features = [ 10 + { 11 + icon: Film, 12 + title: "Track Your Media", 13 + description: "Keep track of movies, shows, and games you've watched and played", 14 + }, 15 + { 16 + icon: Shield, 17 + title: "Own Your Data", 18 + description: "Built on AT Protocol - your data belongs to you", 19 + }, 20 + { 21 + icon: Share2, 22 + title: "Discover & Share", 23 + description: "See what others are watching and share your favorites", 24 + }, 25 + ]; 26 + 27 + export default function HomeScreen() { 28 + return ( 29 + <SafeAreaView style={styles.container} edges={["top"]}> 30 + <ScrollView contentContainerStyle={styles.scrollContent}> 31 + <View style={styles.hero}> 32 + <View style={styles.logoContainer}> 33 + <Film size={64} color={colors.primary} /> 34 + </View> 35 + <Text style={styles.title}>OpnShelf</Text> 36 + <Text style={styles.subtitle}> 37 + Your personal media tracker powered by AT Protocol 38 + </Text> 39 + <Button 40 + size="lg" 41 + onPress={() => router.push("/(tabs)/search")} 42 + style={styles.searchButton} 43 + > 44 + <Search size={20} color={colors.text} style={styles.buttonIcon} /> 45 + <Text style={styles.buttonText}>Search Movies</Text> 46 + </Button> 47 + </View> 48 + 49 + <View style={styles.features}> 50 + {features.map((feature, index) => ( 51 + <Card key={index} style={styles.featureCard}> 52 + <CardHeader> 53 + <feature.icon size={32} color={colors.primary} style={styles.featureIcon} /> 54 + <Text style={styles.featureTitle}>{feature.title}</Text> 55 + </CardHeader> 56 + <CardContent> 57 + <Text style={styles.featureDescription}>{feature.description}</Text> 58 + </CardContent> 59 + </Card> 60 + ))} 61 + </View> 62 + </ScrollView> 63 + </SafeAreaView> 64 + ); 65 + } 66 + 67 + const styles = StyleSheet.create({ 68 + container: { 69 + flex: 1, 70 + backgroundColor: colors.background, 71 + }, 72 + scrollContent: { 73 + padding: spacing.lg, 74 + }, 75 + hero: { 76 + alignItems: "center", 77 + paddingVertical: spacing.xxl, 78 + }, 79 + logoContainer: { 80 + marginBottom: spacing.lg, 81 + }, 82 + title: { 83 + fontSize: 40, 84 + fontWeight: "bold", 85 + color: colors.text, 86 + marginBottom: spacing.sm, 87 + }, 88 + subtitle: { 89 + fontSize: 16, 90 + color: colors.textMuted, 91 + textAlign: "center", 92 + marginBottom: spacing.xl, 93 + paddingHorizontal: spacing.lg, 94 + }, 95 + searchButton: { 96 + minWidth: 200, 97 + }, 98 + buttonIcon: { 99 + marginRight: spacing.sm, 100 + }, 101 + buttonText: { 102 + color: colors.text, 103 + fontSize: 16, 104 + fontWeight: "600", 105 + }, 106 + features: { 107 + gap: spacing.md, 108 + }, 109 + featureCard: { 110 + marginBottom: spacing.md, 111 + }, 112 + featureIcon: { 113 + marginBottom: spacing.sm, 114 + }, 115 + featureTitle: { 116 + fontSize: 18, 117 + fontWeight: "600", 118 + color: colors.text, 119 + }, 120 + featureDescription: { 121 + fontSize: 14, 122 + color: colors.textMuted, 123 + lineHeight: 20, 124 + }, 125 + });
+398
apps/mobile/app/(tabs)/search.tsx
··· 1 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 + import { 3 + moviesControllerGetUserMoviesOptions, 4 + moviesControllerGetUserMoviesQueryKey, 5 + moviesControllerMarkWatchedMutation, 6 + moviesControllerSearchMoviesOptions, 7 + moviesControllerUnmarkWatchedMutation, 8 + type TmdbMovieResultDto, 9 + } from "@opnshelf/api"; 10 + import { FlashList, type ListRenderItem } from "@shopify/flash-list"; 11 + import { router } from "expo-router"; 12 + import { Check, Loader2, Plus } from "lucide-react-native"; 13 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 14 + import { 15 + ActivityIndicator, 16 + Alert, 17 + Pressable, 18 + StyleSheet, 19 + Text, 20 + View, 21 + } from "react-native"; 22 + import { SafeAreaView } from "react-native-safe-area-context"; 23 + import { useAuth } from "@/contexts/auth"; 24 + import { Badge } from "@/components/ui/Badge"; 25 + import { SearchInput } from "@/components/ui/Input"; 26 + import { Skeleton } from "@/components/ui/Skeleton"; 27 + import { colors, spacing, borderRadius } from "@/constants/theme"; 28 + import { Image } from "expo-image"; 29 + 30 + const DEBOUNCE_MS = 300; 31 + const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w342"; 32 + 33 + function createTitleSlug(title: string): string { 34 + return title 35 + .replace(/[^a-zA-Z0-9\s-]/g, "") 36 + .trim() 37 + .replace(/\s+/g, "-"); 38 + } 39 + 40 + interface MovieItemProps { 41 + movie: TmdbMovieResultDto; 42 + isWatched: boolean; 43 + isMarking: boolean; 44 + isUnmarking: boolean; 45 + onToggle: (movieId: string, isWatched: boolean) => void; 46 + onPress: () => void; 47 + } 48 + 49 + const MovieItem = ({ movie, isWatched, isMarking, isUnmarking, onToggle, onPress }: MovieItemProps) => { 50 + const handleToggle = useCallback(() => { 51 + onToggle(movie.id.toString(), isWatched); 52 + }, [movie.id, isWatched, onToggle]); 53 + 54 + const isPending = isMarking || isUnmarking; 55 + 56 + return ( 57 + <View style={styles.movieItem}> 58 + <Pressable onPress={onPress} style={styles.posterContainer}> 59 + {movie.poster_path ? ( 60 + <Image 61 + source={{ uri: `${POSTER_BASE_URL}${movie.poster_path}` }} 62 + style={styles.poster} 63 + contentFit="cover" 64 + transition={200} 65 + /> 66 + ) : ( 67 + <View style={[styles.poster, styles.noPoster]}> 68 + <Text style={styles.noPosterText}>No poster</Text> 69 + </View> 70 + )} 71 + {/* Quick add button */} 72 + <Pressable 73 + onPress={handleToggle} 74 + disabled={isPending} 75 + style={[ 76 + styles.quickAddButton, 77 + isWatched && styles.quickAddButtonWatched, 78 + ]} 79 + > 80 + {isPending ? ( 81 + <Loader2 size={16} color={colors.text} /> 82 + ) : isWatched ? ( 83 + <Check size={16} color={colors.text} /> 84 + ) : ( 85 + <Plus size={16} color={colors.text} /> 86 + )} 87 + </Pressable> 88 + </Pressable> 89 + <Pressable onPress={onPress}> 90 + <Text style={styles.movieTitle} numberOfLines={2}> 91 + {movie.title} 92 + </Text> 93 + {movie.release_date && ( 94 + <Text style={styles.movieYear}> 95 + {movie.release_date.split("-")[0]} 96 + </Text> 97 + )} 98 + </Pressable> 99 + </View> 100 + ); 101 + }; 102 + 103 + export default function SearchScreen() { 104 + const [query, setQuery] = useState(""); 105 + const [debouncedQuery, setDebouncedQuery] = useState(""); 106 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 107 + const { user } = useAuth(); 108 + const queryClient = useQueryClient(); 109 + 110 + // Debounce search query 111 + useEffect(() => { 112 + if (debounceRef.current) { 113 + clearTimeout(debounceRef.current); 114 + } 115 + 116 + debounceRef.current = setTimeout(() => { 117 + setDebouncedQuery(query.trim()); 118 + }, DEBOUNCE_MS); 119 + 120 + return () => { 121 + if (debounceRef.current) { 122 + clearTimeout(debounceRef.current); 123 + } 124 + }; 125 + }, [query]); 126 + 127 + // Fetch user's tracked movies 128 + const { data: trackedMovies } = useQuery({ 129 + ...moviesControllerGetUserMoviesOptions({ 130 + path: { userDid: user?.did || "" }, 131 + }), 132 + enabled: !!user?.did, 133 + }); 134 + 135 + // Build set of watched movie IDs 136 + const watchedMovieIds = useMemo(() => { 137 + if (!trackedMovies) return new Set<string>(); 138 + return new Set(trackedMovies.map((m) => m.movieId)); 139 + }, [trackedMovies]); 140 + 141 + // Search movies using generated hook 142 + const { data, isLoading, error } = useQuery({ 143 + ...moviesControllerSearchMoviesOptions({ 144 + query: { query: debouncedQuery }, 145 + }), 146 + enabled: debouncedQuery.length > 0, 147 + }); 148 + 149 + // Mark watched mutation 150 + const markMutation = useMutation({ 151 + ...moviesControllerMarkWatchedMutation(), 152 + onSuccess: () => { 153 + queryClient.invalidateQueries({ 154 + queryKey: moviesControllerGetUserMoviesQueryKey({ 155 + path: { userDid: user?.did || "" }, 156 + }), 157 + }); 158 + Alert.alert("Success", "Added to your shelf"); 159 + }, 160 + onError: () => { 161 + Alert.alert("Error", "Failed to add to shelf. Please try again."); 162 + }, 163 + }); 164 + 165 + // Unmark watched mutation 166 + const unmarkMutation = useMutation({ 167 + ...moviesControllerUnmarkWatchedMutation(), 168 + onSuccess: () => { 169 + queryClient.invalidateQueries({ 170 + queryKey: moviesControllerGetUserMoviesQueryKey({ 171 + path: { userDid: user?.did || "" }, 172 + }), 173 + }); 174 + Alert.alert("Success", "Removed from your shelf"); 175 + }, 176 + onError: () => { 177 + Alert.alert("Error", "Failed to remove from shelf. Please try again."); 178 + }, 179 + }); 180 + 181 + const handleToggleWatched = useCallback( 182 + (movieId: string, isWatched: boolean) => { 183 + if (!user) { 184 + Alert.alert("Sign In Required", "Please sign in to track movies", [ 185 + { text: "Cancel", style: "cancel" }, 186 + { text: "Sign In", onPress: () => router.push("/(tabs)/shelf") }, 187 + ]); 188 + return; 189 + } 190 + 191 + if (isWatched) { 192 + unmarkMutation.mutate({ path: { movieId } }); 193 + } else { 194 + markMutation.mutate({ body: { movieId } }); 195 + } 196 + }, 197 + [user, markMutation, unmarkMutation] 198 + ); 199 + 200 + const handleMoviePress = useCallback( 201 + (movie: { id: number; title: string }) => { 202 + router.push({ 203 + pathname: "/movie/[id]", 204 + params: { 205 + id: movie.id.toString(), 206 + title: createTitleSlug(movie.title), 207 + }, 208 + }); 209 + }, 210 + [] 211 + ); 212 + 213 + const renderMovieItem: ListRenderItem<TmdbMovieResultDto> = useCallback( 214 + ({ item }) => { 215 + const movieId = item.id.toString(); 216 + const isWatched = watchedMovieIds.has(movieId); 217 + const isMarking = 218 + markMutation.isPending && markMutation.variables?.body?.movieId === movieId; 219 + const isUnmarking = 220 + unmarkMutation.isPending && unmarkMutation.variables?.path?.movieId === movieId; 221 + 222 + return ( 223 + <MovieItem 224 + movie={item} 225 + isWatched={isWatched} 226 + isMarking={isMarking} 227 + isUnmarking={isUnmarking} 228 + onToggle={handleToggleWatched} 229 + onPress={() => handleMoviePress(item)} 230 + /> 231 + ); 232 + }, 233 + [watchedMovieIds, markMutation, unmarkMutation, handleToggleWatched, handleMoviePress] 234 + ); 235 + 236 + const keyExtractor = useCallback((item: TmdbMovieResultDto) => item.id.toString(), []); 237 + 238 + const renderSkeleton = () => ( 239 + <View style={styles.skeletonGrid}> 240 + {[...Array(10)].map((_, i) => ( 241 + <View key={i} style={styles.movieItem}> 242 + <Skeleton width="100%" height={180} borderRadius={borderRadius.lg} /> 243 + <View style={{ marginTop: spacing.sm }}> 244 + <Skeleton width="80%" height={16} /> 245 + </View> 246 + <View style={{ marginTop: spacing.xs }}> 247 + <Skeleton width="50%" height={14} /> 248 + </View> 249 + </View> 250 + ))} 251 + </View> 252 + ); 253 + 254 + return ( 255 + <SafeAreaView style={styles.container} edges={["top"]}> 256 + <View style={styles.header}> 257 + <Text style={styles.title}>Search Movies</Text> 258 + </View> 259 + 260 + <SearchInput 261 + value={query} 262 + onChangeText={setQuery} 263 + placeholder="Search for a movie..." 264 + containerStyle={styles.searchInput} 265 + /> 266 + 267 + {isLoading && renderSkeleton()} 268 + 269 + {error && ( 270 + <View style={styles.centerContent}> 271 + <Text style={styles.errorText}>Error: {error.message}</Text> 272 + </View> 273 + )} 274 + 275 + {data && data.results.length > 0 && ( 276 + <FlashList 277 + data={data.results} 278 + renderItem={renderMovieItem} 279 + keyExtractor={keyExtractor} 280 + numColumns={2} 281 + contentContainerStyle={styles.listContent} 282 + ListHeaderComponent={ 283 + <Text style={styles.resultsCount}> 284 + Found {data.total_results.toLocaleString()} results 285 + </Text> 286 + } 287 + /> 288 + )} 289 + 290 + {data && data.results.length === 0 && debouncedQuery && ( 291 + <View style={styles.centerContent}> 292 + <Text style={styles.emptyText}> 293 + No results found for "{debouncedQuery}" 294 + </Text> 295 + </View> 296 + )} 297 + </SafeAreaView> 298 + ); 299 + } 300 + 301 + const styles = StyleSheet.create({ 302 + container: { 303 + flex: 1, 304 + backgroundColor: colors.background, 305 + }, 306 + header: { 307 + paddingHorizontal: spacing.lg, 308 + paddingVertical: spacing.md, 309 + }, 310 + title: { 311 + fontSize: 28, 312 + fontWeight: "bold", 313 + color: colors.text, 314 + }, 315 + searchInput: { 316 + marginHorizontal: spacing.lg, 317 + marginBottom: spacing.md, 318 + }, 319 + listContent: { 320 + padding: spacing.lg, 321 + }, 322 + movieItem: { 323 + flex: 1, 324 + marginBottom: spacing.lg, 325 + maxWidth: "48%", 326 + }, 327 + posterContainer: { 328 + aspectRatio: 2 / 3, 329 + borderRadius: borderRadius.lg, 330 + overflow: "hidden", 331 + backgroundColor: colors.card, 332 + position: "relative", 333 + }, 334 + poster: { 335 + width: "100%", 336 + height: "100%", 337 + }, 338 + noPoster: { 339 + justifyContent: "center", 340 + alignItems: "center", 341 + }, 342 + noPosterText: { 343 + color: colors.textSecondary, 344 + fontSize: 12, 345 + }, 346 + quickAddButton: { 347 + position: "absolute", 348 + top: spacing.sm, 349 + right: spacing.sm, 350 + width: 32, 351 + height: 32, 352 + borderRadius: borderRadius.full, 353 + backgroundColor: colors.primary, 354 + justifyContent: "center", 355 + alignItems: "center", 356 + }, 357 + quickAddButtonWatched: { 358 + backgroundColor: colors.success, 359 + }, 360 + movieTitle: { 361 + fontSize: 14, 362 + fontWeight: "600", 363 + color: colors.text, 364 + marginTop: spacing.sm, 365 + }, 366 + movieYear: { 367 + fontSize: 12, 368 + color: colors.textMuted, 369 + marginTop: 2, 370 + }, 371 + resultsCount: { 372 + fontSize: 14, 373 + color: colors.textMuted, 374 + marginBottom: spacing.md, 375 + }, 376 + centerContent: { 377 + flex: 1, 378 + justifyContent: "center", 379 + alignItems: "center", 380 + padding: spacing.xl, 381 + }, 382 + emptyText: { 383 + fontSize: 16, 384 + color: colors.textMuted, 385 + textAlign: "center", 386 + }, 387 + errorText: { 388 + fontSize: 14, 389 + color: colors.error, 390 + textAlign: "center", 391 + }, 392 + skeletonGrid: { 393 + flexDirection: "row", 394 + flexWrap: "wrap", 395 + padding: spacing.lg, 396 + gap: spacing.md, 397 + }, 398 + });
+424
apps/mobile/app/(tabs)/shelf.tsx
··· 1 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 + import { 3 + moviesControllerGetUserMoviesOptions, 4 + moviesControllerGetUserMoviesQueryKey, 5 + moviesControllerUnmarkWatchedMutation, 6 + } from "@opnshelf/api"; 7 + import type { TrackedMovieDto } from "@opnshelf/api"; 8 + import { FlashList } from "@shopify/flash-list"; 9 + import { router } from "expo-router"; 10 + import { BookOpen, Loader2, LogIn, Trash2 } from "lucide-react-native"; 11 + import { useCallback } from "react"; 12 + import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; 13 + import { SafeAreaView } from "react-native-safe-area-context"; 14 + import { useAuth } from "@/contexts/auth"; 15 + import { Badge } from "@/components/ui/Badge"; 16 + import { Button } from "@/components/ui/Button"; 17 + import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 18 + import { Skeleton } from "@/components/ui/Skeleton"; 19 + import { colors, spacing, borderRadius } from "@/constants/theme"; 20 + import { Image } from "expo-image"; 21 + import { format } from "date-fns"; 22 + 23 + const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w342"; 24 + 25 + function createTitleSlug(title: string): string { 26 + return title 27 + .replace(/[^a-zA-Z0-9\s-]/g, "") 28 + .trim() 29 + .replace(/\s+/g, "-"); 30 + } 31 + 32 + interface TrackedMovieItemProps { 33 + tracked: TrackedMovieDto; 34 + isRemoving: boolean; 35 + onRemove: (movieId: string) => void; 36 + onPress: () => void; 37 + } 38 + 39 + const TrackedMovieItem = ({ tracked, isRemoving, onRemove, onPress }: TrackedMovieItemProps) => { 40 + const handleRemove = useCallback(() => { 41 + Alert.alert( 42 + "Remove from Shelf", 43 + `Are you sure you want to remove "${tracked.movie.title}" from your shelf?`, 44 + [ 45 + { text: "Cancel", style: "cancel" }, 46 + { text: "Remove", style: "destructive", onPress: () => onRemove(tracked.movieId) }, 47 + ] 48 + ); 49 + }, [tracked.movie.title, tracked.movieId, onRemove]); 50 + 51 + return ( 52 + <View style={styles.movieItem}> 53 + <Pressable onPress={onPress} style={styles.posterContainer}> 54 + {tracked.movie.posterPath ? ( 55 + <Image 56 + source={{ uri: `${POSTER_BASE_URL}${tracked.movie.posterPath}` }} 57 + style={styles.poster} 58 + contentFit="cover" 59 + transition={200} 60 + /> 61 + ) : ( 62 + <View style={[styles.poster, styles.noPoster]}> 63 + <Text style={styles.noPosterText}>No poster</Text> 64 + </View> 65 + )} 66 + {/* Remove button */} 67 + <Pressable 68 + onPress={handleRemove} 69 + disabled={isRemoving} 70 + style={styles.removeButton} 71 + > 72 + {isRemoving ? ( 73 + <Loader2 size={16} color={colors.text} /> 74 + ) : ( 75 + <Trash2 size={16} color={colors.error} /> 76 + )} 77 + </Pressable> 78 + </Pressable> 79 + <Pressable onPress={onPress}> 80 + <Text style={styles.movieTitle} numberOfLines={2}> 81 + {tracked.movie.title} 82 + </Text> 83 + {tracked.movie.releaseYear && ( 84 + <Text style={styles.movieYear}>{tracked.movie.releaseYear}</Text> 85 + )} 86 + {tracked.watchedDate && ( 87 + <View style={styles.watchedInfo}> 88 + <Text style={styles.watchedDate}> 89 + Watched {format(new Date(tracked.watchedDate), "MMM d, yyyy HH:mm")} 90 + </Text> 91 + </View> 92 + )} 93 + </Pressable> 94 + </View> 95 + ); 96 + }; 97 + 98 + export default function ShelfScreen() { 99 + const { user, isLoading: isAuthLoading, isAuthenticated, login } = useAuth(); 100 + const queryClient = useQueryClient(); 101 + 102 + // Fetch user's tracked movies 103 + const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 104 + ...moviesControllerGetUserMoviesOptions({ 105 + path: { userDid: user?.did || "" }, 106 + }), 107 + enabled: !!user?.did, 108 + }); 109 + 110 + // Remove from shelf mutation 111 + const unmarkMutation = useMutation({ 112 + ...moviesControllerUnmarkWatchedMutation(), 113 + onSuccess: () => { 114 + queryClient.invalidateQueries({ 115 + queryKey: moviesControllerGetUserMoviesQueryKey({ 116 + path: { userDid: user?.did || "" }, 117 + }), 118 + }); 119 + Alert.alert("Success", "Removed from your shelf"); 120 + }, 121 + onError: () => { 122 + Alert.alert("Error", "Failed to remove from shelf. Please try again."); 123 + }, 124 + }); 125 + 126 + const handleRemove = useCallback( 127 + (movieId: string) => { 128 + unmarkMutation.mutate({ path: { movieId } }); 129 + }, 130 + [unmarkMutation] 131 + ); 132 + 133 + const handleMoviePress = useCallback( 134 + (tracked: TrackedMovieDto) => { 135 + router.push({ 136 + pathname: "/movie/[id]", 137 + params: { 138 + id: tracked.movieId, 139 + title: createTitleSlug(tracked.movie.title), 140 + }, 141 + }); 142 + }, 143 + [] 144 + ); 145 + 146 + const renderMovieItem = useCallback( 147 + ({ item }: { item: TrackedMovieDto }) => { 148 + const isRemoving = 149 + unmarkMutation.isPending && unmarkMutation.variables?.path?.movieId === item.movieId; 150 + 151 + return ( 152 + <TrackedMovieItem 153 + tracked={item} 154 + isRemoving={isRemoving} 155 + onRemove={handleRemove} 156 + onPress={() => handleMoviePress(item)} 157 + /> 158 + ); 159 + }, 160 + [unmarkMutation, handleRemove, handleMoviePress] 161 + ); 162 + 163 + const keyExtractor = useCallback((item: TrackedMovieDto) => item.id, []); 164 + 165 + // Loading state 166 + if (isAuthLoading) { 167 + return ( 168 + <SafeAreaView style={styles.container} edges={["top"]}> 169 + <View style={styles.header}> 170 + <Text style={styles.title}>My Shelf</Text> 171 + </View> 172 + <View style={styles.skeletonGrid}> 173 + {[...Array(8)].map((_, i) => ( 174 + <View key={i} style={styles.movieItem}> 175 + <Skeleton width="100%" height={180} borderRadius={borderRadius.lg} /> 176 + <View style={{ marginTop: spacing.sm }}> 177 + <Skeleton width="80%" height={16} /> 178 + </View> 179 + <View style={{ marginTop: spacing.xs }}> 180 + <Skeleton width="50%" height={14} /> 181 + </View> 182 + </View> 183 + ))} 184 + </View> 185 + </SafeAreaView> 186 + ); 187 + } 188 + 189 + // Not authenticated state 190 + if (!isAuthenticated) { 191 + return ( 192 + <SafeAreaView style={styles.container} edges={["top"]}> 193 + <View style={styles.centerContent}> 194 + <Card style={styles.authCard}> 195 + <CardHeader> 196 + <BookOpen size={64} color={colors.primary} style={styles.authIcon} /> 197 + <Text style={styles.authTitle}>My Shelf</Text> 198 + <Text style={styles.authDescription}> 199 + Sign in to track movies you've watched 200 + </Text> 201 + </CardHeader> 202 + <CardContent> 203 + <Button size="lg" onPress={() => login()}> 204 + <LogIn size={20} color={colors.text} style={styles.buttonIcon} /> 205 + <Text style={styles.buttonText}>Sign in</Text> 206 + </Button> 207 + </CardContent> 208 + </Card> 209 + </View> 210 + </SafeAreaView> 211 + ); 212 + } 213 + 214 + return ( 215 + <SafeAreaView style={styles.container} edges={["top"]}> 216 + <View style={styles.header}> 217 + <View style={styles.headerContent}> 218 + <BookOpen size={32} color={colors.primary} /> 219 + <Text style={styles.title}>My Shelf</Text> 220 + </View> 221 + </View> 222 + 223 + {isMoviesLoading && ( 224 + <View style={styles.skeletonGrid}> 225 + {[...Array(8)].map((_, i) => ( 226 + <View key={i} style={styles.movieItem}> 227 + <Skeleton width="100%" height={180} borderRadius={borderRadius.lg} /> 228 + <View style={{ marginTop: spacing.sm }}> 229 + <Skeleton width="80%" height={16} /> 230 + </View> 231 + <View style={{ marginTop: spacing.xs }}> 232 + <Skeleton width="50%" height={14} /> 233 + </View> 234 + </View> 235 + ))} 236 + </View> 237 + )} 238 + 239 + {trackedMovies && trackedMovies.length > 0 && ( 240 + <FlashList 241 + data={trackedMovies} 242 + renderItem={renderMovieItem} 243 + keyExtractor={keyExtractor} 244 + numColumns={2} 245 + contentContainerStyle={styles.listContent} 246 + ListHeaderComponent={ 247 + <Text style={styles.resultsCount}> 248 + {trackedMovies.length} movie 249 + {trackedMovies.length !== 1 ? "s" : ""} watched 250 + </Text> 251 + } 252 + /> 253 + )} 254 + 255 + {trackedMovies && trackedMovies.length === 0 && ( 256 + <View style={styles.centerContent}> 257 + <Card style={styles.emptyCard}> 258 + <CardHeader> 259 + <BookOpen size={64} color={colors.textSecondary} style={styles.emptyIcon} /> 260 + <Text style={styles.emptyTitle}>Your shelf is empty</Text> 261 + <Text style={styles.emptyDescription}> 262 + Start tracking movies you've watched 263 + </Text> 264 + </CardHeader> 265 + <CardContent> 266 + <Button onPress={() => router.push("/(tabs)/search")}> 267 + <Text style={styles.buttonText}>Search for movies</Text> 268 + </Button> 269 + </CardContent> 270 + </Card> 271 + </View> 272 + )} 273 + </SafeAreaView> 274 + ); 275 + } 276 + 277 + const styles = StyleSheet.create({ 278 + container: { 279 + flex: 1, 280 + backgroundColor: colors.background, 281 + }, 282 + header: { 283 + paddingHorizontal: spacing.lg, 284 + paddingVertical: spacing.md, 285 + }, 286 + headerContent: { 287 + flexDirection: "row", 288 + alignItems: "center", 289 + gap: spacing.sm, 290 + }, 291 + title: { 292 + fontSize: 28, 293 + fontWeight: "bold", 294 + color: colors.text, 295 + }, 296 + listContent: { 297 + padding: spacing.lg, 298 + }, 299 + movieItem: { 300 + flex: 1, 301 + marginBottom: spacing.lg, 302 + maxWidth: "48%", 303 + }, 304 + posterContainer: { 305 + aspectRatio: 2 / 3, 306 + borderRadius: borderRadius.lg, 307 + overflow: "hidden", 308 + backgroundColor: colors.card, 309 + position: "relative", 310 + }, 311 + poster: { 312 + width: "100%", 313 + height: "100%", 314 + }, 315 + noPoster: { 316 + justifyContent: "center", 317 + alignItems: "center", 318 + }, 319 + noPosterText: { 320 + color: colors.textSecondary, 321 + fontSize: 12, 322 + }, 323 + removeButton: { 324 + position: "absolute", 325 + top: spacing.sm, 326 + right: spacing.sm, 327 + width: 32, 328 + height: 32, 329 + borderRadius: borderRadius.full, 330 + backgroundColor: colors.card, 331 + justifyContent: "center", 332 + alignItems: "center", 333 + }, 334 + movieTitle: { 335 + fontSize: 14, 336 + fontWeight: "600", 337 + color: colors.text, 338 + marginTop: spacing.sm, 339 + }, 340 + movieYear: { 341 + fontSize: 12, 342 + color: colors.textMuted, 343 + marginTop: 2, 344 + }, 345 + watchedInfo: { 346 + flexDirection: "row", 347 + alignItems: "center", 348 + marginTop: 4, 349 + gap: spacing.xs, 350 + }, 351 + watchedDate: { 352 + fontSize: 11, 353 + color: colors.textMuted, 354 + }, 355 + watchCount: { 356 + paddingHorizontal: 6, 357 + paddingVertical: 2, 358 + }, 359 + resultsCount: { 360 + fontSize: 14, 361 + color: colors.textMuted, 362 + marginBottom: spacing.md, 363 + }, 364 + centerContent: { 365 + flex: 1, 366 + justifyContent: "center", 367 + alignItems: "center", 368 + padding: spacing.xl, 369 + }, 370 + authCard: { 371 + width: "100%", 372 + maxWidth: 400, 373 + alignItems: "center", 374 + }, 375 + authIcon: { 376 + marginBottom: spacing.md, 377 + }, 378 + authTitle: { 379 + fontSize: 24, 380 + fontWeight: "bold", 381 + color: colors.text, 382 + textAlign: "center", 383 + marginBottom: spacing.sm, 384 + }, 385 + authDescription: { 386 + fontSize: 16, 387 + color: colors.textMuted, 388 + textAlign: "center", 389 + }, 390 + emptyCard: { 391 + width: "100%", 392 + maxWidth: 400, 393 + alignItems: "center", 394 + }, 395 + emptyIcon: { 396 + marginBottom: spacing.md, 397 + }, 398 + emptyTitle: { 399 + fontSize: 20, 400 + fontWeight: "bold", 401 + color: colors.text, 402 + textAlign: "center", 403 + marginBottom: spacing.sm, 404 + }, 405 + emptyDescription: { 406 + fontSize: 14, 407 + color: colors.textMuted, 408 + textAlign: "center", 409 + }, 410 + buttonIcon: { 411 + marginRight: spacing.sm, 412 + }, 413 + buttonText: { 414 + color: colors.text, 415 + fontSize: 16, 416 + fontWeight: "600", 417 + }, 418 + skeletonGrid: { 419 + flexDirection: "row", 420 + flexWrap: "wrap", 421 + padding: spacing.lg, 422 + gap: spacing.md, 423 + }, 424 + });
+49 -1
apps/mobile/app/_layout.tsx
··· 1 + import { QueryClientProvider } from "@tanstack/react-query"; 1 2 import { Stack } from "expo-router"; 3 + import { StatusBar } from "expo-status-bar"; 4 + import { useEffect } from "react"; 5 + import { AuthProvider } from "@/contexts/auth"; 6 + import { initializeApiClient } from "@/lib/api"; 7 + import { queryClient } from "@/lib/query-client"; 8 + import { colors } from "@/constants/theme"; 2 9 3 10 export default function RootLayout() { 4 - return <Stack />; 11 + useEffect(() => { 12 + initializeApiClient(); 13 + }, []); 14 + 15 + return ( 16 + <QueryClientProvider client={queryClient}> 17 + <AuthProvider> 18 + <Stack 19 + screenOptions={{ 20 + headerStyle: { 21 + backgroundColor: colors.background, 22 + }, 23 + headerTintColor: colors.text, 24 + headerTitleStyle: { 25 + color: colors.text, 26 + }, 27 + contentStyle: { 28 + backgroundColor: colors.background, 29 + }, 30 + }} 31 + > 32 + <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> 33 + <Stack.Screen 34 + name="movie/[id]" 35 + options={{ 36 + title: "Movie Details", 37 + headerTransparent: true, 38 + headerTintColor: colors.text, 39 + }} 40 + /> 41 + <Stack.Screen 42 + name="auth/callback" 43 + options={{ 44 + presentation: "modal", 45 + headerShown: false, 46 + }} 47 + /> 48 + </Stack> 49 + <StatusBar style="light" /> 50 + </AuthProvider> 51 + </QueryClientProvider> 52 + ); 5 53 }
+40
apps/mobile/app/auth/callback.tsx
··· 1 + import { useLocalSearchParams, useRouter } from "expo-router"; 2 + import { useEffect } from "react"; 3 + import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; 4 + import { useAuth } from "@/contexts/auth"; 5 + import { colors } from "@/constants/theme"; 6 + 7 + export default function AuthCallbackScreen() { 8 + const { token } = useLocalSearchParams<{ token?: string }>(); 9 + const { handleAuthCallback } = useAuth(); 10 + const router = useRouter(); 11 + 12 + useEffect(() => { 13 + if (token) { 14 + handleAuthCallback(token).then(() => { 15 + router.replace("/(tabs)/shelf"); 16 + }); 17 + } 18 + }, [token, handleAuthCallback, router]); 19 + 20 + return ( 21 + <View style={styles.container}> 22 + <ActivityIndicator size="large" color={colors.primary} /> 23 + <Text style={styles.text}>Completing sign in...</Text> 24 + </View> 25 + ); 26 + } 27 + 28 + const styles = StyleSheet.create({ 29 + container: { 30 + flex: 1, 31 + backgroundColor: colors.background, 32 + justifyContent: "center", 33 + alignItems: "center", 34 + gap: 16, 35 + }, 36 + text: { 37 + color: colors.text, 38 + fontSize: 16, 39 + }, 40 + });
-15
apps/mobile/app/index.tsx
··· 1 - import { Text, View } from "react-native"; 2 - 3 - export default function Index() { 4 - return ( 5 - <View 6 - style={{ 7 - flex: 1, 8 - justifyContent: "center", 9 - alignItems: "center", 10 - }} 11 - > 12 - <Text>Edit app/index.tsx to edit this screen.</Text> 13 - </View> 14 - ); 15 - }
+923
apps/mobile/app/movie/[id].tsx
··· 1 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 + import { 3 + moviesControllerDeleteWatchHistoryEntryMutation, 4 + moviesControllerGetMovieDetailsOptions, 5 + moviesControllerGetMovieWatchHistory, 6 + moviesControllerGetUserMoviesOptions, 7 + moviesControllerGetUserMoviesQueryKey, 8 + moviesControllerMarkWatchedMutation, 9 + moviesControllerUnmarkWatchedMutation, 10 + } from "@opnshelf/api"; 11 + import type { TmdbMovieDetailDto, WatchHistoryItemDto } from "@opnshelf/api"; 12 + import DateTimePicker, { DateTimePickerEvent } from "@react-native-community/datetimepicker"; 13 + import { useLocalSearchParams, useRouter } from "expo-router"; 14 + import { 15 + ArrowLeft, 16 + Calendar, 17 + Check, 18 + Clock, 19 + Eye, 20 + History, 21 + Loader2, 22 + Plus, 23 + RotateCcw, 24 + Trash2, 25 + X, 26 + } from "lucide-react-native"; 27 + import { useCallback, useMemo, useState } from "react"; 28 + import { 29 + Alert, 30 + Modal, 31 + Pressable, 32 + ScrollView, 33 + StyleSheet, 34 + Text, 35 + View, 36 + } from "react-native"; 37 + import { SafeAreaView } from "react-native-safe-area-context"; 38 + import { useAuth } from "@/contexts/auth"; 39 + import { Badge } from "@/components/ui/Badge"; 40 + import { Button } from "@/components/ui/Button"; 41 + import { Card } from "@/components/ui/Card"; 42 + import { Skeleton } from "@/components/ui/Skeleton"; 43 + import { colors, spacing, borderRadius } from "@/constants/theme"; 44 + import { Image } from "expo-image"; 45 + import { format } from "date-fns"; 46 + 47 + const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w500"; 48 + const BACKDROP_BASE_URL = "https://image.tmdb.org/t/p/w1280"; 49 + 50 + function formatRuntime(minutes: number, useHours: boolean): string { 51 + if (!useHours) return `${minutes} min`; 52 + const hours = Math.floor(minutes / 60); 53 + const mins = minutes % 60; 54 + if (mins === 0) return `${hours}h`; 55 + return `${hours}h ${mins}m`; 56 + } 57 + 58 + export default function MovieDetailScreen() { 59 + const { id: movieId, title } = useLocalSearchParams<{ id: string; title?: string }>(); 60 + const router = useRouter(); 61 + const { user } = useAuth(); 62 + const queryClient = useQueryClient(); 63 + 64 + const [showHours, setShowHours] = useState(false); 65 + const [showDateModal, setShowDateModal] = useState(false); 66 + const [customDate, setCustomDate] = useState<Date>(new Date()); 67 + const [showDatePicker, setShowDatePicker] = useState(false); 68 + const [showTimePicker, setShowTimePicker] = useState(false); 69 + const [showHistoryModal, setShowHistoryModal] = useState(false); 70 + 71 + // Fetch movie details 72 + const { data: movieData, isLoading: isMovieLoading } = useQuery({ 73 + ...moviesControllerGetMovieDetailsOptions({ 74 + path: { movieId }, 75 + }), 76 + }); 77 + 78 + const movie = movieData as TmdbMovieDetailDto | undefined; 79 + 80 + // Fetch user's tracked movies 81 + const { data: trackedMovies } = useQuery({ 82 + ...moviesControllerGetUserMoviesOptions({ 83 + path: { userDid: user?.did || "" }, 84 + }), 85 + enabled: !!user?.did, 86 + }); 87 + 88 + // Fetch watch history for this movie 89 + const { data: watchHistory } = useQuery<WatchHistoryItemDto[]>({ 90 + queryKey: ["watchHistory", user?.did, movieId], 91 + queryFn: async () => { 92 + if (!user?.did) return []; 93 + const { data } = await moviesControllerGetMovieWatchHistory({ 94 + path: { userDid: user.did, movieId }, 95 + }); 96 + return data || []; 97 + }, 98 + enabled: !!user?.did && !!movieId, 99 + }); 100 + 101 + // Check if this movie is in user's watched list 102 + const isWatched = useMemo(() => { 103 + if (!trackedMovies) return false; 104 + return trackedMovies.some((tm) => tm.movieId === movieId); 105 + }, [trackedMovies, movieId]); 106 + 107 + // Find the tracked movie entry 108 + const trackedMovie = useMemo(() => { 109 + if (!trackedMovies) return null; 110 + return trackedMovies.find((tm) => tm.movieId === movieId) || null; 111 + }, [trackedMovies, movieId]); 112 + 113 + // Format the watched date 114 + const formattedWatchedDate = useMemo(() => { 115 + if (!trackedMovie?.watchedDate) return null; 116 + return format(new Date(trackedMovie.watchedDate), "MMM d, yyyy HH:mm"); 117 + }, [trackedMovie]); 118 + 119 + // Use server-provided colors with fallbacks 120 + const movieColors = movie?.colors || { 121 + primary: "#8b5cf6", 122 + secondary: "#6366f1", 123 + accent: "#a855f7", 124 + muted: "#4c1d95", 125 + }; 126 + 127 + // Mutations 128 + const markMutation = useMutation({ 129 + ...moviesControllerMarkWatchedMutation(), 130 + onSuccess: () => { 131 + queryClient.invalidateQueries({ 132 + queryKey: moviesControllerGetUserMoviesQueryKey({ 133 + path: { userDid: user?.did || "" }, 134 + }), 135 + }); 136 + queryClient.invalidateQueries({ 137 + queryKey: ["watchHistory", user?.did, movieId], 138 + }); 139 + setShowDateModal(false); 140 + Alert.alert("Success", "Added to your shelf"); 141 + }, 142 + onError: () => { 143 + Alert.alert("Error", "Failed to add. Please try again."); 144 + }, 145 + }); 146 + 147 + const unmarkMutation = useMutation({ 148 + ...moviesControllerUnmarkWatchedMutation(), 149 + onSuccess: () => { 150 + queryClient.invalidateQueries({ 151 + queryKey: moviesControllerGetUserMoviesQueryKey({ 152 + path: { userDid: user?.did || "" }, 153 + }), 154 + }); 155 + queryClient.invalidateQueries({ 156 + queryKey: ["watchHistory", user?.did, movieId], 157 + }); 158 + Alert.alert("Success", "Removed from your shelf"); 159 + }, 160 + onError: () => { 161 + Alert.alert("Error", "Failed to remove. Please try again."); 162 + }, 163 + }); 164 + 165 + const deleteWatchEntryMutation = useMutation({ 166 + ...moviesControllerDeleteWatchHistoryEntryMutation(), 167 + onSuccess: () => { 168 + queryClient.invalidateQueries({ 169 + queryKey: moviesControllerGetUserMoviesQueryKey({ 170 + path: { userDid: user?.did || "" }, 171 + }), 172 + }); 173 + queryClient.invalidateQueries({ 174 + queryKey: ["watchHistory", user?.did, movieId], 175 + }); 176 + Alert.alert("Success", "Watch entry removed"); 177 + }, 178 + onError: () => { 179 + Alert.alert("Error", "Failed to remove watch entry. Please try again."); 180 + }, 181 + }); 182 + 183 + const handleMarkWatched = useCallback(() => { 184 + markMutation.mutate({ body: { movieId } }); 185 + }, [markMutation, movieId]); 186 + 187 + const handleMarkWatchedWithDate = useCallback(() => { 188 + const watchedAt = customDate.toISOString(); 189 + markMutation.mutate({ 190 + body: { movieId, watchedAt }, 191 + }); 192 + }, [markMutation, movieId, customDate]); 193 + 194 + const handleUnmarkWatched = useCallback(() => { 195 + unmarkMutation.mutate({ 196 + path: { movieId }, 197 + query: { mode: "all" }, 198 + }); 199 + }, [unmarkMutation, movieId]); 200 + 201 + const handleDeleteWatchEntry = useCallback( 202 + (trackedMovieId: string) => { 203 + deleteWatchEntryMutation.mutate({ path: { trackedMovieId } }); 204 + }, 205 + [deleteWatchEntryMutation] 206 + ); 207 + 208 + const onDateChange = useCallback((event: DateTimePickerEvent, selectedDate?: Date) => { 209 + setShowDatePicker(false); 210 + if (selectedDate) { 211 + const newDate = new Date(customDate); 212 + newDate.setFullYear(selectedDate.getFullYear()); 213 + newDate.setMonth(selectedDate.getMonth()); 214 + newDate.setDate(selectedDate.getDate()); 215 + setCustomDate(newDate); 216 + setShowTimePicker(true); 217 + } 218 + }, [customDate]); 219 + 220 + const onTimeChange = useCallback((event: DateTimePickerEvent, selectedTime?: Date) => { 221 + setShowTimePicker(false); 222 + if (selectedTime) { 223 + const newDate = new Date(customDate); 224 + newDate.setHours(selectedTime.getHours()); 225 + newDate.setMinutes(selectedTime.getMinutes()); 226 + setCustomDate(newDate); 227 + } 228 + }, [customDate]); 229 + 230 + const openDateModal = useCallback(() => { 231 + setCustomDate(new Date()); 232 + setShowDateModal(true); 233 + }, []); 234 + 235 + const releaseYear = movie?.release_date 236 + ? new Date(movie.release_date).getFullYear() 237 + : null; 238 + 239 + const backdropUrl = movie?.backdrop_path 240 + ? `${BACKDROP_BASE_URL}${movie.backdrop_path}` 241 + : null; 242 + 243 + const posterUrl = movie?.poster_path 244 + ? `${POSTER_BASE_URL}${movie.poster_path}` 245 + : null; 246 + 247 + const isPending = 248 + markMutation.isPending && markMutation.variables?.body?.movieId === movieId; 249 + 250 + if (isMovieLoading) { 251 + return ( 252 + <SafeAreaView style={styles.container}> 253 + <View style={styles.loadingContainer}> 254 + <Skeleton width={80} height={80} borderRadius={borderRadius.full} /> 255 + <View style={{ marginTop: spacing.lg }}> 256 + <Skeleton width={200} height={24} /> 257 + </View> 258 + </View> 259 + </SafeAreaView> 260 + ); 261 + } 262 + 263 + return ( 264 + <View style={styles.container}> 265 + <ScrollView> 266 + {/* Hero Section with Backdrop */} 267 + <View style={styles.heroContainer}> 268 + {backdropUrl ? ( 269 + <> 270 + <Image 271 + source={{ uri: backdropUrl }} 272 + style={styles.backdrop} 273 + contentFit="cover" 274 + /> 275 + <View style={[styles.gradientOverlay, styles.bottomGradient]} /> 276 + <View style={[styles.gradientOverlay, styles.sideGradient]} /> 277 + </> 278 + ) : ( 279 + <View 280 + style={[ 281 + styles.backdrop, 282 + { backgroundColor: movieColors.muted }, 283 + ]} 284 + /> 285 + )} 286 + 287 + {/* Back button */} 288 + <Pressable onPress={() => router.back()} style={styles.backButton}> 289 + <ArrowLeft size={24} color={colors.text} /> 290 + </Pressable> 291 + 292 + {/* Hero Content */} 293 + <View style={styles.heroContent}> 294 + {posterUrl && ( 295 + <View 296 + style={[ 297 + styles.posterContainer, 298 + { shadowColor: movieColors.primary }, 299 + ]} 300 + > 301 + <Image 302 + source={{ uri: posterUrl }} 303 + style={styles.poster} 304 + contentFit="cover" 305 + /> 306 + </View> 307 + )} 308 + 309 + <View style={styles.titleContainer}> 310 + <Text style={[styles.title, { textShadowColor: `${movieColors.primary}60` }]}> 311 + {movie?.title} 312 + </Text> 313 + {releaseYear && ( 314 + <View style={styles.metaRow}> 315 + <View style={styles.metaItem}> 316 + <Calendar size={16} color={movieColors.accent} /> 317 + <Text style={styles.metaText}>{releaseYear}</Text> 318 + </View> 319 + {movie?.runtime && ( 320 + <Pressable onPress={() => setShowHours(!showHours)} style={styles.metaItem}> 321 + <Clock size={16} color={movieColors.accent} /> 322 + <Text style={styles.metaText}> 323 + {formatRuntime(movie.runtime, showHours)} 324 + </Text> 325 + </Pressable> 326 + )} 327 + </View> 328 + )} 329 + </View> 330 + </View> 331 + </View> 332 + 333 + {/* Content */} 334 + <View style={styles.content}> 335 + {/* Actions */} 336 + <View style={styles.actionsContainer}> 337 + {user ? ( 338 + !isWatched ? ( 339 + <> 340 + <Button 341 + size="lg" 342 + onPress={handleMarkWatched} 343 + isLoading={isPending} 344 + style={[ 345 + styles.primaryButton, 346 + { 347 + backgroundColor: movieColors.primary, 348 + shadowColor: movieColors.primary, 349 + }, 350 + ]} 351 + > 352 + <Plus size={20} color={colors.text} style={styles.buttonIcon} /> 353 + <Text style={styles.buttonText}>Add to Shelf</Text> 354 + </Button> 355 + <Button variant="outline" onPress={openDateModal}> 356 + <Calendar size={16} color={colors.textMuted} style={styles.buttonIcon} /> 357 + <Text style={styles.secondaryButtonText}>Add on Different Date</Text> 358 + </Button> 359 + </> 360 + ) : ( 361 + <> 362 + <Card style={styles.watchedCard}> 363 + <View style={styles.watchedHeader}> 364 + <Check size={20} color={colors.success} /> 365 + <Text style={styles.watchedText}>On Your Shelf</Text> 366 + </View> 367 + {formattedWatchedDate && ( 368 + <Text style={styles.watchedDate}> 369 + Watched on {formattedWatchedDate} 370 + </Text> 371 + )} 372 + {(watchHistory?.length ?? 0) > 1 && ( 373 + <> 374 + <View style={styles.watchCount}> 375 + <History size={14} color={colors.textMuted} /> 376 + <Text style={styles.watchCountText}> 377 + {watchHistory?.length} total watches 378 + </Text> 379 + </View> 380 + <Pressable 381 + onPress={() => setShowHistoryModal(true)} 382 + style={styles.viewHistoryButton} 383 + > 384 + <Eye size={16} color={colors.textMuted} /> 385 + <Text style={styles.viewHistoryText}>View all watches</Text> 386 + </Pressable> 387 + </> 388 + )} 389 + {watchHistory?.length === 1 && ( 390 + <Pressable 391 + onPress={handleUnmarkWatched} 392 + disabled={unmarkMutation.isPending} 393 + style={styles.removeButton} 394 + > 395 + {unmarkMutation.isPending ? ( 396 + <Loader2 size={16} color={colors.error} /> 397 + ) : ( 398 + <> 399 + <Trash2 size={16} color={colors.error} /> 400 + <Text style={styles.removeButtonText}>Remove from shelf</Text> 401 + </> 402 + )} 403 + </Pressable> 404 + )} 405 + </Card> 406 + <Button 407 + size="lg" 408 + onPress={handleMarkWatched} 409 + isLoading={isPending} 410 + style={[ 411 + styles.primaryButton, 412 + { 413 + backgroundColor: movieColors.primary, 414 + shadowColor: movieColors.primary, 415 + }, 416 + ]} 417 + > 418 + <RotateCcw size={18} color={colors.text} style={styles.buttonIcon} /> 419 + <Text style={styles.buttonText}>Watch Now</Text> 420 + </Button> 421 + <Button variant="outline" onPress={openDateModal}> 422 + <Calendar size={16} color={colors.textMuted} style={styles.buttonIcon} /> 423 + <Text style={styles.secondaryButtonText}>Watch on Different Date</Text> 424 + </Button> 425 + </> 426 + ) 427 + ) : ( 428 + <Button 429 + size="lg" 430 + onPress={() => router.push("/(tabs)/shelf")} 431 + style={[ 432 + styles.primaryButton, 433 + { 434 + backgroundColor: movieColors.primary, 435 + shadowColor: movieColors.primary, 436 + }, 437 + ]} 438 + > 439 + <Text style={styles.buttonText}>Sign in to Track</Text> 440 + </Button> 441 + )} 442 + </View> 443 + 444 + {/* Overview */} 445 + <View style={styles.section}> 446 + <Text style={[styles.sectionTitle, { color: movieColors.primary }]}> 447 + Overview 448 + </Text> 449 + <Text style={styles.overview}>{movie?.overview || "No overview available."}</Text> 450 + </View> 451 + 452 + {/* Additional Info */} 453 + <View style={styles.infoGrid}> 454 + {movie?.release_date && ( 455 + <Card style={styles.infoCard}> 456 + <Text style={styles.infoLabel}>Release Date</Text> 457 + <Text style={[styles.infoValue, { color: movieColors.accent }]}> 458 + {format(new Date(movie.release_date), "MMMM d, yyyy")} 459 + </Text> 460 + </Card> 461 + )} 462 + {movie?.runtime && ( 463 + <Pressable onPress={() => setShowHours(!showHours)}> 464 + <Card style={styles.infoCard}> 465 + <Text style={styles.infoLabel}>Runtime</Text> 466 + <Text style={[styles.infoValue, { color: movieColors.accent }]}> 467 + {formatRuntime(movie.runtime, showHours)} 468 + </Text> 469 + </Card> 470 + </Pressable> 471 + )} 472 + {movie?.vote_average && ( 473 + <Card style={styles.infoCard}> 474 + <Text style={styles.infoLabel}>Rating</Text> 475 + <Text style={[styles.infoValue, { color: movieColors.accent }]}> 476 + {movie.vote_average.toFixed(1)}/10 477 + </Text> 478 + </Card> 479 + )} 480 + {movie?.vote_count && ( 481 + <Card style={styles.infoCard}> 482 + <Text style={styles.infoLabel}>Votes</Text> 483 + <Text style={[styles.infoValue, { color: movieColors.accent }]}> 484 + {movie.vote_count.toLocaleString()} 485 + </Text> 486 + </Card> 487 + )} 488 + </View> 489 + 490 + {/* Genres */} 491 + {movie?.genres && movie.genres.length > 0 && ( 492 + <View style={styles.section}> 493 + <Text style={[styles.sectionTitle, { color: movieColors.primary }]}> 494 + Genres 495 + </Text> 496 + <View style={styles.genresContainer}> 497 + {movie.genres.map((genre) => ( 498 + <Badge 499 + key={genre.id} 500 + variant="outline" 501 + style={[ 502 + styles.genreBadge, 503 + { 504 + backgroundColor: `${movieColors.primary}20`, 505 + borderColor: `${movieColors.primary}40`, 506 + }, 507 + ]} 508 + > 509 + <Text style={[styles.genreText, { color: movieColors.accent }]}> 510 + {genre.name} 511 + </Text> 512 + </Badge> 513 + ))} 514 + </View> 515 + </View> 516 + )} 517 + </View> 518 + </ScrollView> 519 + 520 + {/* Date Picker Modal */} 521 + <Modal 522 + visible={showDateModal} 523 + animationType="slide" 524 + transparent={true} 525 + onRequestClose={() => setShowDateModal(false)} 526 + > 527 + <View style={styles.modalOverlay}> 528 + <View style={styles.modalContent}> 529 + <View style={styles.modalHeader}> 530 + <Text style={styles.modalTitle}>Watch Again</Text> 531 + <Pressable onPress={() => setShowDateModal(false)}> 532 + <X size={24} color={colors.text} /> 533 + </Pressable> 534 + </View> 535 + <Text style={styles.modalDescription}>When did you watch this movie?</Text> 536 + 537 + <View style={styles.dateTimeContainer}> 538 + <Pressable onPress={() => setShowDatePicker(true)} style={styles.dateTimeButton}> 539 + <Calendar size={20} color={colors.textMuted} /> 540 + <Text style={styles.dateTimeText}> 541 + {format(customDate, "MMMM d, yyyy")} 542 + </Text> 543 + </Pressable> 544 + <Pressable onPress={() => setShowTimePicker(true)} style={styles.dateTimeButton}> 545 + <Clock size={20} color={colors.textMuted} /> 546 + <Text style={styles.dateTimeText}> 547 + {format(customDate, "HH:mm")} 548 + </Text> 549 + </Pressable> 550 + </View> 551 + 552 + <View style={styles.modalActions}> 553 + <Button variant="outline" onPress={() => setShowDateModal(false)}> 554 + <Text style={styles.secondaryButtonText}>Cancel</Text> 555 + </Button> 556 + <Button 557 + onPress={handleMarkWatchedWithDate} 558 + isLoading={markMutation.isPending} 559 + style={{ backgroundColor: colors.primary }} 560 + > 561 + <Text style={styles.buttonText}>Add Play</Text> 562 + </Button> 563 + </View> 564 + </View> 565 + </View> 566 + </Modal> 567 + 568 + {/* History Modal */} 569 + <Modal 570 + visible={showHistoryModal} 571 + animationType="slide" 572 + transparent={true} 573 + onRequestClose={() => setShowHistoryModal(false)} 574 + > 575 + <View style={styles.modalOverlay}> 576 + <View style={styles.modalContent}> 577 + <View style={styles.modalHeader}> 578 + <View style={styles.modalTitleContainer}> 579 + <History size={20} color={colors.text} /> 580 + <Text style={styles.modalTitle}>Watch History</Text> 581 + </View> 582 + <Pressable onPress={() => setShowHistoryModal(false)}> 583 + <X size={24} color={colors.text} /> 584 + </Pressable> 585 + </View> 586 + <Text style={styles.modalDescription}> 587 + All the times you've watched {movie?.title} 588 + </Text> 589 + 590 + <ScrollView style={styles.historyList}> 591 + {watchHistory && watchHistory.length > 0 ? ( 592 + watchHistory.map((watch) => ( 593 + <View key={watch.id} style={styles.historyItem}> 594 + <Text style={styles.historyDate}> 595 + {format(new Date(watch.watchedDate), "MMM d, yyyy HH:mm")} 596 + </Text> 597 + <Pressable 598 + onPress={() => handleDeleteWatchEntry(watch.id)} 599 + disabled={deleteWatchEntryMutation.isPending} 600 + style={styles.historyDeleteButton} 601 + > 602 + {deleteWatchEntryMutation.isPending && 603 + deleteWatchEntryMutation.variables?.path?.trackedMovieId === watch.id ? ( 604 + <Loader2 size={16} color={colors.textMuted} /> 605 + ) : ( 606 + <Trash2 size={16} color={colors.error} /> 607 + )} 608 + </Pressable> 609 + </View> 610 + )) 611 + ) : ( 612 + <Text style={styles.emptyHistory}>No watch history found</Text> 613 + )} 614 + </ScrollView> 615 + 616 + <Button variant="outline" onPress={() => setShowHistoryModal(false)}> 617 + <Text style={styles.secondaryButtonText}>Close</Text> 618 + </Button> 619 + </View> 620 + </View> 621 + </Modal> 622 + 623 + {/* Date/Time Pickers */} 624 + {showDatePicker && ( 625 + <DateTimePicker 626 + value={customDate} 627 + mode="date" 628 + onChange={onDateChange} 629 + /> 630 + )} 631 + {showTimePicker && ( 632 + <DateTimePicker 633 + value={customDate} 634 + mode="time" 635 + is24Hour={true} 636 + onChange={onTimeChange} 637 + /> 638 + )} 639 + </View> 640 + ); 641 + } 642 + 643 + const styles = StyleSheet.create({ 644 + container: { 645 + flex: 1, 646 + backgroundColor: colors.background, 647 + }, 648 + loadingContainer: { 649 + flex: 1, 650 + justifyContent: "center", 651 + alignItems: "center", 652 + }, 653 + heroContainer: { 654 + height: 400, 655 + position: "relative", 656 + }, 657 + backdrop: { 658 + ...StyleSheet.absoluteFillObject, 659 + }, 660 + gradientOverlay: { 661 + ...StyleSheet.absoluteFillObject, 662 + }, 663 + bottomGradient: { 664 + backgroundColor: "rgba(3, 7, 18, 0.6)", 665 + }, 666 + sideGradient: { 667 + backgroundColor: "rgba(3, 7, 18, 0.4)", 668 + }, 669 + backButton: { 670 + position: "absolute", 671 + top: spacing.lg, 672 + left: spacing.lg, 673 + zIndex: 10, 674 + padding: spacing.sm, 675 + borderRadius: borderRadius.full, 676 + backgroundColor: "rgba(0, 0, 0, 0.5)", 677 + }, 678 + heroContent: { 679 + position: "absolute", 680 + bottom: 0, 681 + left: 0, 682 + right: 0, 683 + padding: spacing.lg, 684 + flexDirection: "row", 685 + alignItems: "flex-end", 686 + }, 687 + posterContainer: { 688 + width: 120, 689 + borderRadius: borderRadius.lg, 690 + overflow: "hidden", 691 + shadowOffset: { width: 0, height: 10 }, 692 + shadowOpacity: 0.5, 693 + shadowRadius: 20, 694 + elevation: 10, 695 + }, 696 + poster: { 697 + width: "100%", 698 + aspectRatio: 2 / 3, 699 + }, 700 + titleContainer: { 701 + flex: 1, 702 + marginLeft: spacing.md, 703 + paddingBottom: spacing.sm, 704 + }, 705 + title: { 706 + fontSize: 24, 707 + fontWeight: "bold", 708 + color: colors.text, 709 + textShadowOffset: { width: 0, height: 4 }, 710 + textShadowRadius: 30, 711 + marginBottom: spacing.sm, 712 + }, 713 + metaRow: { 714 + flexDirection: "row", 715 + gap: spacing.md, 716 + }, 717 + metaItem: { 718 + flexDirection: "row", 719 + alignItems: "center", 720 + gap: spacing.xs, 721 + }, 722 + metaText: { 723 + fontSize: 14, 724 + color: colors.textMuted, 725 + }, 726 + content: { 727 + padding: spacing.lg, 728 + }, 729 + actionsContainer: { 730 + gap: spacing.md, 731 + marginBottom: spacing.xl, 732 + }, 733 + primaryButton: { 734 + shadowOffset: { width: 0, height: 10 }, 735 + shadowOpacity: 0.3, 736 + shadowRadius: 15, 737 + elevation: 5, 738 + }, 739 + buttonIcon: { 740 + marginRight: spacing.sm, 741 + }, 742 + buttonText: { 743 + color: colors.text, 744 + fontSize: 16, 745 + fontWeight: "600", 746 + }, 747 + secondaryButtonText: { 748 + color: colors.textMuted, 749 + fontSize: 16, 750 + fontWeight: "600", 751 + }, 752 + watchedCard: { 753 + padding: spacing.md, 754 + }, 755 + watchedHeader: { 756 + flexDirection: "row", 757 + alignItems: "center", 758 + gap: spacing.xs, 759 + marginBottom: spacing.xs, 760 + }, 761 + watchedText: { 762 + color: colors.success, 763 + fontSize: 16, 764 + fontWeight: "600", 765 + }, 766 + watchedDate: { 767 + fontSize: 14, 768 + color: colors.textMuted, 769 + marginBottom: spacing.xs, 770 + }, 771 + watchCount: { 772 + flexDirection: "row", 773 + alignItems: "center", 774 + gap: spacing.xs, 775 + marginTop: spacing.xs, 776 + }, 777 + watchCountText: { 778 + fontSize: 12, 779 + color: colors.textMuted, 780 + }, 781 + viewHistoryButton: { 782 + flexDirection: "row", 783 + alignItems: "center", 784 + gap: spacing.xs, 785 + marginTop: spacing.sm, 786 + }, 787 + viewHistoryText: { 788 + fontSize: 14, 789 + color: colors.textMuted, 790 + }, 791 + removeButton: { 792 + flexDirection: "row", 793 + alignItems: "center", 794 + gap: spacing.xs, 795 + marginTop: spacing.md, 796 + }, 797 + removeButtonText: { 798 + fontSize: 14, 799 + color: colors.error, 800 + }, 801 + section: { 802 + marginBottom: spacing.xl, 803 + }, 804 + sectionTitle: { 805 + fontSize: 20, 806 + fontWeight: "600", 807 + marginBottom: spacing.md, 808 + }, 809 + overview: { 810 + fontSize: 16, 811 + color: colors.textMuted, 812 + lineHeight: 24, 813 + }, 814 + infoGrid: { 815 + flexDirection: "row", 816 + flexWrap: "wrap", 817 + gap: spacing.md, 818 + marginBottom: spacing.xl, 819 + }, 820 + infoCard: { 821 + flex: 1, 822 + minWidth: "45%", 823 + padding: spacing.md, 824 + }, 825 + infoLabel: { 826 + fontSize: 12, 827 + color: colors.textMuted, 828 + marginBottom: spacing.xs, 829 + }, 830 + infoValue: { 831 + fontSize: 16, 832 + fontWeight: "600", 833 + }, 834 + genresContainer: { 835 + flexDirection: "row", 836 + flexWrap: "wrap", 837 + gap: spacing.sm, 838 + }, 839 + genreBadge: { 840 + paddingHorizontal: spacing.md, 841 + paddingVertical: spacing.sm, 842 + }, 843 + genreText: { 844 + fontSize: 14, 845 + fontWeight: "500", 846 + }, 847 + modalOverlay: { 848 + flex: 1, 849 + backgroundColor: "rgba(0, 0, 0, 0.7)", 850 + justifyContent: "center", 851 + padding: spacing.lg, 852 + }, 853 + modalContent: { 854 + backgroundColor: colors.card, 855 + borderRadius: borderRadius.xl, 856 + padding: spacing.lg, 857 + gap: spacing.md, 858 + }, 859 + modalHeader: { 860 + flexDirection: "row", 861 + justifyContent: "space-between", 862 + alignItems: "center", 863 + }, 864 + modalTitleContainer: { 865 + flexDirection: "row", 866 + alignItems: "center", 867 + gap: spacing.sm, 868 + }, 869 + modalTitle: { 870 + fontSize: 20, 871 + fontWeight: "bold", 872 + color: colors.text, 873 + }, 874 + modalDescription: { 875 + fontSize: 14, 876 + color: colors.textMuted, 877 + }, 878 + dateTimeContainer: { 879 + gap: spacing.md, 880 + }, 881 + dateTimeButton: { 882 + flexDirection: "row", 883 + alignItems: "center", 884 + gap: spacing.md, 885 + padding: spacing.md, 886 + backgroundColor: colors.cardMuted, 887 + borderRadius: borderRadius.lg, 888 + }, 889 + dateTimeText: { 890 + fontSize: 16, 891 + color: colors.text, 892 + }, 893 + modalActions: { 894 + flexDirection: "row", 895 + gap: spacing.md, 896 + marginTop: spacing.md, 897 + }, 898 + historyList: { 899 + maxHeight: 300, 900 + }, 901 + historyItem: { 902 + flexDirection: "row", 903 + justifyContent: "space-between", 904 + alignItems: "center", 905 + padding: spacing.md, 906 + backgroundColor: colors.cardMuted, 907 + borderRadius: borderRadius.lg, 908 + marginBottom: spacing.sm, 909 + }, 910 + historyDate: { 911 + fontSize: 14, 912 + color: colors.text, 913 + fontWeight: "500", 914 + }, 915 + historyDeleteButton: { 916 + padding: spacing.sm, 917 + }, 918 + emptyHistory: { 919 + textAlign: "center", 920 + color: colors.textMuted, 921 + padding: spacing.xl, 922 + }, 923 + });
+55
apps/mobile/components/ui/Badge.tsx
··· 1 + import { StyleSheet, View, Text, type StyleProp, type ViewStyle } from "react-native"; 2 + import { colors, borderRadius } from "@/constants/theme"; 3 + 4 + interface BadgeProps { 5 + children: React.ReactNode; 6 + variant?: "default" | "secondary" | "success" | "outline"; 7 + style?: StyleProp<ViewStyle>; 8 + } 9 + 10 + export function Badge({ children, variant = "default", style }: BadgeProps) { 11 + return ( 12 + <View style={[styles.base, styles[variant], style]}> 13 + <Text style={[styles.text, styles[`${variant}Text`]]}>{children}</Text> 14 + </View> 15 + ); 16 + } 17 + 18 + const styles = StyleSheet.create({ 19 + base: { 20 + paddingHorizontal: 8, 21 + paddingVertical: 2, 22 + borderRadius: borderRadius.full, 23 + alignSelf: "flex-start", 24 + }, 25 + default: { 26 + backgroundColor: colors.primary, 27 + }, 28 + secondary: { 29 + backgroundColor: colors.cardMuted, 30 + }, 31 + success: { 32 + backgroundColor: colors.success, 33 + }, 34 + outline: { 35 + backgroundColor: "transparent", 36 + borderWidth: 1, 37 + borderColor: colors.borderLight, 38 + }, 39 + text: { 40 + fontSize: 12, 41 + fontWeight: "500", 42 + }, 43 + defaultText: { 44 + color: colors.text, 45 + }, 46 + secondaryText: { 47 + color: colors.textMuted, 48 + }, 49 + successText: { 50 + color: colors.text, 51 + }, 52 + outlineText: { 53 + color: colors.textMuted, 54 + }, 55 + });
+100
apps/mobile/components/ui/Button.tsx
··· 1 + import { ActivityIndicator, Pressable, StyleSheet, Text, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; 2 + import { colors, borderRadius } from "@/constants/theme"; 3 + 4 + interface ButtonProps extends PressableProps { 5 + variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive"; 6 + size?: "sm" | "md" | "lg"; 7 + isLoading?: boolean; 8 + children: React.ReactNode; 9 + style?: StyleProp<ViewStyle>; 10 + } 11 + 12 + export function Button({ 13 + variant = "primary", 14 + size = "md", 15 + isLoading = false, 16 + children, 17 + style, 18 + disabled, 19 + ...props 20 + }: ButtonProps) { 21 + const buttonStyles = [ 22 + styles.base, 23 + styles[variant], 24 + styles[size], 25 + (disabled || isLoading) && styles.disabled, 26 + style, 27 + ]; 28 + 29 + return ( 30 + <Pressable style={buttonStyles} disabled={disabled || isLoading} {...props}> 31 + {isLoading ? ( 32 + <ActivityIndicator size="small" color={variant === "primary" ? colors.text : colors.primary} /> 33 + ) : typeof children === "string" ? ( 34 + <Text style={[styles.text, styles[`${variant}Text`]]}>{children}</Text> 35 + ) : ( 36 + children 37 + )} 38 + </Pressable> 39 + ); 40 + } 41 + 42 + const styles = StyleSheet.create({ 43 + base: { 44 + flexDirection: "row", 45 + alignItems: "center", 46 + justifyContent: "center", 47 + borderRadius: borderRadius.lg, 48 + }, 49 + sm: { 50 + paddingVertical: 6, 51 + paddingHorizontal: 12, 52 + }, 53 + md: { 54 + paddingVertical: 10, 55 + paddingHorizontal: 16, 56 + }, 57 + lg: { 58 + paddingVertical: 14, 59 + paddingHorizontal: 24, 60 + }, 61 + primary: { 62 + backgroundColor: colors.primary, 63 + }, 64 + secondary: { 65 + backgroundColor: colors.card, 66 + }, 67 + outline: { 68 + backgroundColor: "transparent", 69 + borderWidth: 1, 70 + borderColor: colors.borderLight, 71 + }, 72 + ghost: { 73 + backgroundColor: "transparent", 74 + }, 75 + destructive: { 76 + backgroundColor: colors.error, 77 + }, 78 + disabled: { 79 + opacity: 0.5, 80 + }, 81 + text: { 82 + fontSize: 16, 83 + fontWeight: "600", 84 + }, 85 + primaryText: { 86 + color: colors.text, 87 + }, 88 + secondaryText: { 89 + color: colors.text, 90 + }, 91 + outlineText: { 92 + color: colors.text, 93 + }, 94 + ghostText: { 95 + color: colors.textMuted, 96 + }, 97 + destructiveText: { 98 + color: colors.text, 99 + }, 100 + });
+84
apps/mobile/components/ui/Card.tsx
··· 1 + import { StyleSheet, View, type ViewStyle } from "react-native"; 2 + import { colors, borderRadius, spacing } from "@/constants/theme"; 3 + 4 + interface CardProps { 5 + children: React.ReactNode; 6 + style?: ViewStyle; 7 + variant?: "default" | "muted"; 8 + } 9 + 10 + export function Card({ children, style, variant = "default" }: CardProps) { 11 + return ( 12 + <View style={[styles.base, styles[variant], style]}> 13 + {children} 14 + </View> 15 + ); 16 + } 17 + 18 + interface CardContentProps { 19 + children: React.ReactNode; 20 + style?: ViewStyle; 21 + } 22 + 23 + export function CardContent({ children, style }: CardContentProps) { 24 + return <View style={[styles.content, style]}>{children}</View>; 25 + } 26 + 27 + interface CardHeaderProps { 28 + children: React.ReactNode; 29 + style?: ViewStyle; 30 + } 31 + 32 + export function CardHeader({ children, style }: CardHeaderProps) { 33 + return <View style={[styles.header, style]}>{children}</View>; 34 + } 35 + 36 + interface CardTitleProps { 37 + children: React.ReactNode; 38 + } 39 + 40 + export function CardTitle({ children }: CardTitleProps) { 41 + return <Text style={styles.title}>{children}</Text>; 42 + } 43 + 44 + interface CardDescriptionProps { 45 + children: React.ReactNode; 46 + } 47 + 48 + export function CardDescription({ children }: CardDescriptionProps) { 49 + return <Text style={styles.description}>{children}</Text>; 50 + } 51 + 52 + import { Text } from "react-native"; 53 + 54 + const styles = StyleSheet.create({ 55 + base: { 56 + borderRadius: borderRadius.lg, 57 + overflow: "hidden", 58 + }, 59 + default: { 60 + backgroundColor: colors.card, 61 + borderWidth: 1, 62 + borderColor: colors.border, 63 + }, 64 + muted: { 65 + backgroundColor: colors.cardMuted, 66 + }, 67 + content: { 68 + padding: spacing.md, 69 + }, 70 + header: { 71 + padding: spacing.md, 72 + paddingBottom: spacing.sm, 73 + }, 74 + title: { 75 + fontSize: 18, 76 + fontWeight: "600", 77 + color: colors.text, 78 + marginBottom: spacing.xs, 79 + }, 80 + description: { 81 + fontSize: 14, 82 + color: colors.textMuted, 83 + }, 84 + });
+59
apps/mobile/components/ui/Input.tsx
··· 1 + import { StyleSheet, View, TextInput, type TextInputProps, ViewStyle } from "react-native"; 2 + import { colors, borderRadius, spacing } from "@/constants/theme"; 3 + import { Search } from "lucide-react-native"; 4 + 5 + interface InputProps extends TextInputProps { 6 + icon?: React.ReactNode; 7 + containerStyle?: ViewStyle; 8 + } 9 + 10 + export function Input({ icon, containerStyle, style, ...props }: InputProps) { 11 + return ( 12 + <View style={[styles.container, containerStyle]}> 13 + {icon ? <View style={styles.icon}>{icon}</View> : null} 14 + <TextInput 15 + style={[styles.input, icon ? styles.inputWithIcon : null, style]} 16 + placeholderTextColor={colors.textSecondary} 17 + {...props} 18 + /> 19 + </View> 20 + ); 21 + } 22 + 23 + interface SearchInputProps extends Omit<TextInputProps, "icon"> { 24 + containerStyle?: ViewStyle; 25 + } 26 + 27 + export function SearchInput({ containerStyle, ...props }: SearchInputProps) { 28 + return ( 29 + <Input 30 + icon={<Search size={20} color={colors.textMuted} />} 31 + containerStyle={containerStyle} 32 + {...props} 33 + /> 34 + ); 35 + } 36 + 37 + const styles = StyleSheet.create({ 38 + container: { 39 + flexDirection: "row", 40 + alignItems: "center", 41 + backgroundColor: colors.card, 42 + borderRadius: borderRadius.lg, 43 + borderWidth: 1, 44 + borderColor: colors.border, 45 + }, 46 + icon: { 47 + paddingLeft: spacing.md, 48 + }, 49 + input: { 50 + flex: 1, 51 + paddingVertical: 12, 52 + paddingHorizontal: spacing.md, 53 + color: colors.text, 54 + fontSize: 16, 55 + }, 56 + inputWithIcon: { 57 + paddingLeft: spacing.sm, 58 + }, 59 + });
+27
apps/mobile/components/ui/Skeleton.tsx
··· 1 + import { StyleSheet, View, type ViewStyle } from "react-native"; 2 + import { colors, borderRadius } from "@/constants/theme"; 3 + 4 + interface SkeletonProps { 5 + width?: number | `${number}%`; 6 + height?: number; 7 + borderRadius?: number; 8 + style?: ViewStyle; 9 + } 10 + 11 + export function Skeleton({ width = "100%", height = 16, borderRadius: br = borderRadius.md, style }: SkeletonProps) { 12 + return ( 13 + <View 14 + style={[ 15 + styles.skeleton, 16 + { width, height, borderRadius: br }, 17 + style, 18 + ]} 19 + /> 20 + ); 21 + } 22 + 23 + const styles = StyleSheet.create({ 24 + skeleton: { 25 + backgroundColor: colors.cardMuted, 26 + }, 27 + });
+47
apps/mobile/constants/theme.ts
··· 1 + export const colors = { 2 + // Background 3 + background: "#030712", // gray-950 4 + card: "#111827", // gray-900 5 + cardMuted: "#1f2937", // gray-800 6 + 7 + // Text 8 + text: "#f9fafb", // gray-50 9 + textMuted: "#9ca3af", // gray-400 10 + textSecondary: "#6b7280", // gray-500 11 + 12 + // Brand 13 + primary: "#8b5cf6", // purple-500 14 + primaryLight: "#a78bfa", // purple-400 15 + primaryDark: "#7c3aed", // purple-600 16 + 17 + // Accents 18 + accent: "#a855f7", // purple-600 19 + secondary: "#6366f1", // indigo-500 20 + 21 + // Status 22 + success: "#22c55e", // green-500 23 + error: "#ef4444", // red-500 24 + warning: "#f59e0b", // amber-500 25 + 26 + // Borders 27 + border: "#1f2937", // gray-800 28 + borderLight: "#374151", // gray-700 29 + } as const; 30 + 31 + export const spacing = { 32 + xs: 4, 33 + sm: 8, 34 + md: 16, 35 + lg: 24, 36 + xl: 32, 37 + xxl: 48, 38 + } as const; 39 + 40 + export const borderRadius = { 41 + sm: 4, 42 + md: 8, 43 + lg: 12, 44 + xl: 16, 45 + xxl: 24, 46 + full: 9999, 47 + } as const;
+82
apps/mobile/contexts/auth.tsx
··· 1 + import type { UserDto } from "@opnshelf/api"; 2 + import { useQuery, useQueryClient } from "@tanstack/react-query"; 3 + import { authControllerMeOptions, getLoginUrl } from "@opnshelf/api"; 4 + import * as WebBrowser from "expo-web-browser"; 5 + import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; 6 + import { loadSessionToken, saveSessionToken } from "@/lib/api"; 7 + 8 + interface AuthContextType { 9 + user: UserDto | null; 10 + isLoading: boolean; 11 + isAuthenticated: boolean; 12 + login: (handle?: string) => Promise<void>; 13 + logout: () => Promise<void>; 14 + handleAuthCallback: (token: string) => Promise<void>; 15 + } 16 + 17 + const AuthContext = createContext<AuthContextType | null>(null); 18 + 19 + export function AuthProvider({ children }: { children: ReactNode }) { 20 + const [isInitialized, setIsInitialized] = useState(false); 21 + const queryClient = useQueryClient(); 22 + 23 + const { data: user, isLoading: isUserLoading } = useQuery({ 24 + ...authControllerMeOptions(), 25 + staleTime: 5 * 60 * 1000, 26 + retry: false, 27 + enabled: isInitialized, 28 + }); 29 + 30 + useEffect(() => { 31 + loadSessionToken().then(() => { 32 + setIsInitialized(true); 33 + }); 34 + }, []); 35 + 36 + const login = useCallback(async (handle?: string) => { 37 + const loginUrl = getLoginUrl(handle); 38 + const result = await WebBrowser.openAuthSessionAsync( 39 + loginUrl, 40 + "opnshelf://auth/callback" 41 + ); 42 + 43 + if (result.type === "success") { 44 + const url = new URL(result.url); 45 + const token = url.searchParams.get("token"); 46 + if (token) { 47 + await handleAuthCallback(token); 48 + } 49 + } 50 + }, []); 51 + 52 + const logout = useCallback(async () => { 53 + await saveSessionToken(null); 54 + queryClient.removeQueries({ queryKey: ["authControllerMe"] }); 55 + queryClient.removeQueries({ queryKey: ["moviesControllerGetUserMovies"] }); 56 + }, [queryClient]); 57 + 58 + const handleAuthCallback = useCallback(async (token: string) => { 59 + await saveSessionToken(token); 60 + // Refetch user to update auth state 61 + await queryClient.invalidateQueries({ queryKey: ["authControllerMe"] }); 62 + }, [queryClient]); 63 + 64 + const value: AuthContextType = { 65 + user: user ?? null, 66 + isLoading: !isInitialized || isUserLoading, 67 + isAuthenticated: !!user, 68 + login, 69 + logout, 70 + handleAuthCallback, 71 + }; 72 + 73 + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; 74 + } 75 + 76 + export function useAuth() { 77 + const context = useContext(AuthContext); 78 + if (!context) { 79 + throw new Error("useAuth must be used within an AuthProvider"); 80 + } 81 + return context; 82 + }
+37
apps/mobile/lib/api.ts
··· 1 + import { configureApiClient, setSessionToken } from "@opnshelf/api"; 2 + import * as SecureStore from "expo-secure-store"; 3 + 4 + const API_URL = process.env.EXPO_PUBLIC_API_URL || "http://127.0.0.1:3001"; 5 + 6 + const SESSION_TOKEN_KEY = "opnshelf_session_token"; 7 + 8 + export function initializeApiClient() { 9 + configureApiClient(API_URL); 10 + } 11 + 12 + export async function loadSessionToken(): Promise<string | null> { 13 + try { 14 + const token = await SecureStore.getItemAsync(SESSION_TOKEN_KEY); 15 + if (token) { 16 + setSessionToken(token); 17 + } 18 + return token; 19 + } catch (error) { 20 + console.error("Failed to load session token:", error); 21 + return null; 22 + } 23 + } 24 + 25 + export async function saveSessionToken(token: string | null): Promise<void> { 26 + try { 27 + if (token) { 28 + await SecureStore.setItemAsync(SESSION_TOKEN_KEY, token); 29 + setSessionToken(token); 30 + } else { 31 + await SecureStore.deleteItemAsync(SESSION_TOKEN_KEY); 32 + setSessionToken(null); 33 + } 34 + } catch (error) { 35 + console.error("Failed to save session token:", error); 36 + } 37 + }
+10
apps/mobile/lib/query-client.ts
··· 1 + import { QueryClient } from "@tanstack/react-query"; 2 + 3 + export const queryClient = new QueryClient({ 4 + defaultOptions: { 5 + queries: { 6 + staleTime: 60 * 1000, // 1 minute 7 + refetchOnWindowFocus: false, 8 + }, 9 + }, 10 + });
+13 -3
apps/mobile/package.json
··· 5 5 "scripts": { 6 6 "dev": "expo start", 7 7 "reset-project": "node ./scripts/reset-project.js", 8 - "android": "expo start --android", 9 - "ios": "expo start --ios", 8 + "android": "expo run:android", 9 + "ios": "expo run:ios", 10 10 "web": "expo start --web", 11 - "lint": "expo lint" 11 + "lint": "expo lint", 12 + "typecheck": "tsc --noEmit" 12 13 }, 13 14 "dependencies": { 15 + "@expo/metro-runtime": "^6.1.2", 14 16 "@expo/vector-icons": "^15.0.3", 17 + "@opnshelf/api": "workspace:*", 18 + "@opnshelf/types": "workspace:*", 19 + "@react-native-community/datetimepicker": "^8.6.0", 15 20 "@react-navigation/bottom-tabs": "^7.4.0", 16 21 "@react-navigation/elements": "^2.6.3", 17 22 "@react-navigation/native": "^7.1.8", 23 + "@shopify/flash-list": "^2.2.2", 24 + "@tanstack/react-query": "^5.90.20", 25 + "date-fns": "^4.1.0", 18 26 "expo": "~54.0.33", 19 27 "expo-constants": "~18.0.13", 20 28 "expo-dev-client": "~6.0.20", ··· 23 31 "expo-image": "~3.0.11", 24 32 "expo-linking": "~8.0.11", 25 33 "expo-router": "~6.0.23", 34 + "expo-secure-store": "^15.0.8", 26 35 "expo-splash-screen": "~31.0.13", 27 36 "expo-status-bar": "~3.0.9", 28 37 "expo-symbols": "~1.0.8", 29 38 "expo-system-ui": "~6.0.9", 30 39 "expo-web-browser": "~15.0.10", 40 + "lucide-react-native": "^0.563.0", 31 41 "react": "19.1.0", 32 42 "react-dom": "19.1.0", 33 43 "react-native": "0.81.5",
+1 -3
apps/mobile/tsconfig.json
··· 10 10 }, 11 11 "include": [ 12 12 "**/*.ts", 13 - "**/*.tsx", 14 - ".expo/types/**/*.ts", 15 - "expo-env.d.ts" 13 + "**/*.tsx" 16 14 ] 17 15 }
+162 -32
pnpm-lock.yaml
··· 14 14 15 15 apps/mobile: 16 16 dependencies: 17 + '@expo/metro-runtime': 18 + specifier: ^6.1.2 19 + version: 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 17 20 '@expo/vector-icons': 18 21 specifier: ^15.0.3 19 22 version: 15.0.3(expo-font@14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 23 + '@opnshelf/api': 24 + specifier: workspace:* 25 + version: link:../../packages/api 26 + '@opnshelf/types': 27 + specifier: workspace:* 28 + version: link:../../packages/types 29 + '@react-native-community/datetimepicker': 30 + specifier: ^8.6.0 31 + version: 8.6.0(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 20 32 '@react-navigation/bottom-tabs': 21 33 specifier: ^7.4.0 22 34 version: 7.12.0(@react-navigation/native@7.1.28(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) ··· 26 38 '@react-navigation/native': 27 39 specifier: ^7.1.8 28 40 version: 7.1.28(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 41 + '@shopify/flash-list': 42 + specifier: ^2.2.2 43 + version: 2.2.2(@babel/runtime@7.28.6)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 44 + '@tanstack/react-query': 45 + specifier: ^5.90.20 46 + version: 5.90.20(react@19.1.0) 47 + date-fns: 48 + specifier: ^4.1.0 49 + version: 4.1.0 29 50 expo: 30 51 specifier: ~54.0.33 31 - version: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 52 + version: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 32 53 expo-constants: 33 54 specifier: ~18.0.13 34 55 version: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) ··· 49 70 version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 50 71 expo-router: 51 72 specifier: ~6.0.23 52 - version: 6.0.23(7b0271988d3f94ed3d3507a5fd980a46) 73 + version: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3) 74 + expo-secure-store: 75 + specifier: ^15.0.8 76 + version: 15.0.8(expo@54.0.33) 53 77 expo-splash-screen: 54 78 specifier: ~31.0.13 55 79 version: 31.0.13(expo@54.0.33) ··· 65 89 expo-web-browser: 66 90 specifier: ~15.0.10 67 91 version: 15.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) 92 + lucide-react-native: 93 + specifier: ^0.563.0 94 + version: 0.563.0(react-native-svg@15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 68 95 react: 69 96 specifier: 19.1.0 70 97 version: 19.1.0 ··· 1539 1566 expo: 1540 1567 optional: true 1541 1568 1542 - '@expo/metro-runtime@4.0.1': 1543 - resolution: {integrity: sha512-CRpbLvdJ1T42S+lrYa1iZp1KfDeBp4oeZOK3hdpiS5n0vR0nhD6sC1gGF0sTboCTp64tLteikz5Y3j53dvgOIw==} 1569 + '@expo/metro-runtime@6.1.2': 1570 + resolution: {integrity: sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==} 1544 1571 peerDependencies: 1572 + expo: '*' 1573 + react: '*' 1574 + react-dom: '*' 1545 1575 react-native: '*' 1576 + peerDependenciesMeta: 1577 + react-dom: 1578 + optional: true 1546 1579 1547 1580 '@expo/metro@54.2.0': 1548 1581 resolution: {integrity: sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==} ··· 3253 3286 '@radix-ui/rect@1.1.1': 3254 3287 resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} 3255 3288 3289 + '@react-native-community/datetimepicker@8.6.0': 3290 + resolution: {integrity: sha512-yxPSqNfxgpGaqHQIpatqe6ykeBdU/1pdsk/G3x01mY2bpTflLpmVTLqFSJYd3MiZzxNZcMs/j1dQakUczSjcYA==} 3291 + peerDependencies: 3292 + expo: '>=52.0.0' 3293 + react: '*' 3294 + react-native: '*' 3295 + react-native-windows: '*' 3296 + peerDependenciesMeta: 3297 + expo: 3298 + optional: true 3299 + react-native-windows: 3300 + optional: true 3301 + 3256 3302 '@react-native/assets-registry@0.81.5': 3257 3303 resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==} 3258 3304 engines: {node: '>= 20.19.4'} ··· 3511 3557 3512 3558 '@scarf/scarf@1.4.0': 3513 3559 resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} 3560 + 3561 + '@shopify/flash-list@2.2.2': 3562 + resolution: {integrity: sha512-YrvLBK5FCpvuX+d9QvJvjVqyi4eBUaEamkyfh9CjPdF6c+AukP0RSBh97qHyTwOEaVq21A5ukwgyWMDIbmxpmQ==} 3563 + peerDependencies: 3564 + '@babel/runtime': '*' 3565 + react: '*' 3566 + react-native: '*' 3514 3567 3515 3568 '@sinclair/typebox@0.27.8': 3516 3569 resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} ··· 5079 5132 css-select@5.2.2: 5080 5133 resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} 5081 5134 5135 + css-tree@1.1.3: 5136 + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} 5137 + engines: {node: '>=8.0.0'} 5138 + 5082 5139 css-tree@3.1.0: 5083 5140 resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} 5084 5141 engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} ··· 5763 5820 optional: true 5764 5821 react-server-dom-webpack: 5765 5822 optional: true 5823 + 5824 + expo-secure-store@15.0.8: 5825 + resolution: {integrity: sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==} 5826 + peerDependencies: 5827 + expo: '*' 5766 5828 5767 5829 expo-server@1.0.5: 5768 5830 resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} ··· 7005 7067 resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} 7006 7068 engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} 7007 7069 7070 + lucide-react-native@0.563.0: 7071 + resolution: {integrity: sha512-q4tYoAMorTqv+UXRYc0MyiEAOF+4Bu73zxD63EDrnGCFL+xuj+imBm3E2rIKRmME0heVHlK+98fsi8wbL92LNQ==} 7072 + peerDependencies: 7073 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 7074 + react-native: '*' 7075 + react-native-svg: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 7076 + 7008 7077 lucide-react@0.561.0: 7009 7078 resolution: {integrity: sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==} 7010 7079 peerDependencies: ··· 7036 7105 math-intrinsics@1.1.0: 7037 7106 resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} 7038 7107 engines: {node: '>= 0.4'} 7108 + 7109 + mdn-data@2.0.14: 7110 + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} 7039 7111 7040 7112 mdn-data@2.12.2: 7041 7113 resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} ··· 7949 8021 7950 8022 react-native-screens@4.16.0: 7951 8023 resolution: {integrity: sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==} 8024 + peerDependencies: 8025 + react: '*' 8026 + react-native: '*' 8027 + 8028 + react-native-svg@15.15.3: 8029 + resolution: {integrity: sha512-/k4KYwPBLGcx2f5d4FjE+vCScK7QOX14cl2lIASJ28u4slHHtIhL0SZKU7u9qmRBHxTCKPoPBtN6haT1NENJNA==} 7952 8030 peerDependencies: 7953 8031 react: '*' 7954 8032 react-native: '*' ··· 10904 10982 connect: 3.7.0 10905 10983 debug: 4.4.3 10906 10984 env-editor: 0.4.2 10907 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10985 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10908 10986 expo-server: 1.0.5 10909 10987 freeport-async: 2.0.0 10910 10988 getenv: 2.0.0 ··· 10937 11015 wrap-ansi: 7.0.0 10938 11016 ws: 8.19.0 10939 11017 optionalDependencies: 10940 - expo-router: 6.0.23(7b0271988d3f94ed3d3507a5fd980a46) 11018 + expo-router: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3) 10941 11019 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 10942 11020 transitivePeerDependencies: 10943 11021 - bufferutil ··· 11070 11148 postcss: 8.4.49 11071 11149 resolve-from: 5.0.0 11072 11150 optionalDependencies: 11073 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 11151 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 11074 11152 transitivePeerDependencies: 11075 11153 - bufferutil 11076 11154 - supports-color 11077 11155 - utf-8-validate 11078 11156 11079 - '@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))': 11157 + '@expo/metro-runtime@6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': 11080 11158 dependencies: 11159 + anser: 1.4.10 11160 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 11161 + pretty-format: 29.7.0 11162 + react: 19.1.0 11081 11163 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 11164 + stacktrace-parser: 0.1.11 11165 + whatwg-fetch: 3.6.20 11166 + optionalDependencies: 11167 + react-dom: 19.1.0(react@19.1.0) 11082 11168 11083 11169 '@expo/metro@54.2.0': 11084 11170 dependencies: ··· 11130 11216 '@expo/json-file': 10.0.8 11131 11217 '@react-native/normalize-colors': 0.81.5 11132 11218 debug: 4.4.3 11133 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 11219 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 11134 11220 resolve-from: 5.0.0 11135 11221 semver: 7.7.3 11136 11222 xml2js: 0.6.0 ··· 13207 13293 13208 13294 '@radix-ui/rect@1.1.1': {} 13209 13295 13296 + '@react-native-community/datetimepicker@8.6.0(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': 13297 + dependencies: 13298 + invariant: 2.2.4 13299 + react: 19.1.0 13300 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 13301 + optionalDependencies: 13302 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 13303 + 13210 13304 '@react-native/assets-registry@0.81.5': {} 13211 13305 13212 13306 '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.28.6)': ··· 13474 13568 13475 13569 '@scarf/scarf@1.4.0': {} 13476 13570 13571 + '@shopify/flash-list@2.2.2(@babel/runtime@7.28.6)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': 13572 + dependencies: 13573 + '@babel/runtime': 7.28.6 13574 + react: 19.1.0 13575 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 13576 + 13477 13577 '@sinclair/typebox@0.27.8': {} 13478 13578 13479 13579 '@sinclair/typebox@0.34.48': {} ··· 13686 13786 '@tanstack/react-query': 5.90.20(react@19.2.4) 13687 13787 react: 19.2.4 13688 13788 13789 + '@tanstack/react-query@5.90.20(react@19.1.0)': 13790 + dependencies: 13791 + '@tanstack/query-core': 5.90.20 13792 + react: 19.1.0 13793 + 13689 13794 '@tanstack/react-query@5.90.20(react@19.2.4)': 13690 13795 dependencies: 13691 13796 '@tanstack/query-core': 5.90.20 ··· 14780 14885 resolve-from: 5.0.0 14781 14886 optionalDependencies: 14782 14887 '@babel/runtime': 7.28.6 14783 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 14888 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 14784 14889 transitivePeerDependencies: 14785 14890 - '@babel/core' 14786 14891 - supports-color ··· 15317 15422 domutils: 3.2.2 15318 15423 nth-check: 2.1.1 15319 15424 15425 + css-tree@1.1.3: 15426 + dependencies: 15427 + mdn-data: 2.0.14 15428 + source-map: 0.6.1 15429 + 15320 15430 css-tree@3.1.0: 15321 15431 dependencies: 15322 15432 mdn-data: 2.12.2 ··· 15949 16059 expo-asset@12.0.12(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 15950 16060 dependencies: 15951 16061 '@expo/image-utils': 0.8.8 15952 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16062 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15953 16063 expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) 15954 16064 react: 19.1.0 15955 16065 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) ··· 15960 16070 dependencies: 15961 16071 '@expo/config': 12.0.13 15962 16072 '@expo/env': 2.0.8 15963 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16073 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15964 16074 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 15965 16075 transitivePeerDependencies: 15966 16076 - supports-color 15967 16077 15968 16078 expo-dev-client@6.0.20(expo@54.0.33): 15969 16079 dependencies: 15970 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16080 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15971 16081 expo-dev-launcher: 6.0.20(expo@54.0.33) 15972 16082 expo-dev-menu: 7.0.18(expo@54.0.33) 15973 16083 expo-dev-menu-interface: 2.0.0(expo@54.0.33) ··· 15979 16089 expo-dev-launcher@6.0.20(expo@54.0.33): 15980 16090 dependencies: 15981 16091 ajv: 8.17.1 15982 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16092 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15983 16093 expo-dev-menu: 7.0.18(expo@54.0.33) 15984 16094 expo-manifests: 1.0.10(expo@54.0.33) 15985 16095 transitivePeerDependencies: ··· 15987 16097 15988 16098 expo-dev-menu-interface@2.0.0(expo@54.0.33): 15989 16099 dependencies: 15990 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16100 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15991 16101 15992 16102 expo-dev-menu@7.0.18(expo@54.0.33): 15993 16103 dependencies: 15994 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16104 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15995 16105 expo-dev-menu-interface: 2.0.0(expo@54.0.33) 15996 16106 15997 16107 expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)): 15998 16108 dependencies: 15999 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16109 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16000 16110 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 16001 16111 16002 16112 expo-font@14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 16003 16113 dependencies: 16004 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16114 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16005 16115 fontfaceobserver: 2.3.0 16006 16116 react: 19.1.0 16007 16117 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 16008 16118 16009 16119 expo-haptics@15.0.8(expo@54.0.33): 16010 16120 dependencies: 16011 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16121 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16012 16122 16013 16123 expo-image@3.0.11(expo@54.0.33)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 16014 16124 dependencies: 16015 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16125 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16016 16126 react: 19.1.0 16017 16127 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 16018 16128 optionalDependencies: ··· 16022 16132 16023 16133 expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0): 16024 16134 dependencies: 16025 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16135 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16026 16136 react: 19.1.0 16027 16137 16028 16138 expo-linking@8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): ··· 16038 16148 expo-manifests@1.0.10(expo@54.0.33): 16039 16149 dependencies: 16040 16150 '@expo/config': 12.0.13 16041 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16151 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16042 16152 expo-json-utils: 0.15.0 16043 16153 transitivePeerDependencies: 16044 16154 - supports-color ··· 16057 16167 react: 19.1.0 16058 16168 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 16059 16169 16060 - expo-router@6.0.23(7b0271988d3f94ed3d3507a5fd980a46): 16170 + expo-router@6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3): 16061 16171 dependencies: 16062 - '@expo/metro-runtime': 4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) 16172 + '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16063 16173 '@expo/schema-utils': 0.1.8 16064 16174 '@radix-ui/react-slot': 1.2.0(@types/react@19.1.17)(react@19.1.0) 16065 16175 '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ··· 16069 16179 client-only: 0.0.1 16070 16180 debug: 4.4.3 16071 16181 escape-string-regexp: 4.0.0 16072 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16182 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16073 16183 expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) 16074 16184 expo-linking: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16075 16185 expo-server: 1.0.5 ··· 16100 16210 - '@types/react-dom' 16101 16211 - supports-color 16102 16212 16213 + expo-secure-store@15.0.8(expo@54.0.33): 16214 + dependencies: 16215 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16216 + 16103 16217 expo-server@1.0.5: {} 16104 16218 16105 16219 expo-splash-screen@31.0.13(expo@54.0.33): 16106 16220 dependencies: 16107 16221 '@expo/prebuild-config': 54.0.8(expo@54.0.33) 16108 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16222 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16109 16223 transitivePeerDependencies: 16110 16224 - supports-color 16111 16225 ··· 16117 16231 16118 16232 expo-symbols@1.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)): 16119 16233 dependencies: 16120 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16234 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16121 16235 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 16122 16236 sf-symbols-typescript: 2.2.0 16123 16237 ··· 16125 16239 dependencies: 16126 16240 '@react-native/normalize-colors': 0.81.5 16127 16241 debug: 4.4.3 16128 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16242 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16129 16243 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 16130 16244 optionalDependencies: 16131 16245 react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ··· 16134 16248 16135 16249 expo-updates-interface@2.0.0(expo@54.0.33): 16136 16250 dependencies: 16137 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16251 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16138 16252 16139 16253 expo-web-browser@15.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)): 16140 16254 dependencies: 16141 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16255 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16142 16256 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 16143 16257 16144 - expo@54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 16258 + expo@54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 16145 16259 dependencies: 16146 16260 '@babel/runtime': 7.28.6 16147 16261 '@expo/cli': 54.0.23(expo-router@6.0.23)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) ··· 16167 16281 react-refresh: 0.14.2 16168 16282 whatwg-url-without-unicode: 8.0.0-3 16169 16283 optionalDependencies: 16170 - '@expo/metro-runtime': 4.0.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) 16284 + '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 16171 16285 transitivePeerDependencies: 16172 16286 - '@babel/core' 16173 16287 - bufferutil ··· 17649 17763 17650 17764 lru.min@1.1.3: {} 17651 17765 17766 + lucide-react-native@0.563.0(react-native-svg@15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 17767 + dependencies: 17768 + react: 19.1.0 17769 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 17770 + react-native-svg: 15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 17771 + 17652 17772 lucide-react@0.561.0(react@19.2.4): 17653 17773 dependencies: 17654 17774 react: 19.2.4 ··· 17676 17796 marky@1.3.0: {} 17677 17797 17678 17798 math-intrinsics@1.1.0: {} 17799 + 17800 + mdn-data@2.0.14: {} 17679 17801 17680 17802 mdn-data@2.12.2: {} 17681 17803 ··· 18809 18931 react-freeze: 1.0.4(react@19.1.0) 18810 18932 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 18811 18933 react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 18934 + warn-once: 0.1.1 18935 + 18936 + react-native-svg@15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 18937 + dependencies: 18938 + css-select: 5.2.2 18939 + css-tree: 1.1.3 18940 + react: 19.1.0 18941 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 18812 18942 warn-once: 0.1.1 18813 18943 18814 18944 react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):