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.

feat: move mobile app to biome

+954 -737
+1 -1
.github/workflows/ci.yml
··· 125 125 run: pnpm install --frozen-lockfile 126 126 127 127 - name: Lint 128 - run: pnpm --filter mobile run lint 128 + run: pnpm --filter mobile run check 129 129 130 130 - name: Typecheck 131 131 run: pnpm --filter mobile exec tsc --noEmit
+6 -1
AGENTS.md
··· 64 64 pnpm test:watch # Watch mode 65 65 ``` 66 66 67 - ### Mobile App 67 + ### Mobile App (apps/mobile) 68 + Uses **Biome** (same as web app): 69 + 68 70 ```bash 69 71 cd apps/mobile 72 + pnpm lint # Run linter 73 + pnpm format # Format code 74 + pnpm check # Run both lint and format checks 70 75 pnpm typecheck # TypeScript check only 71 76 ``` 72 77
+4 -2
apps/mobile/app/(tabs)/_layout.tsx
··· 1 1 import { Tabs } from "expo-router"; 2 - import { Home, Search, Library } from "lucide-react-native"; 2 + import { Home, Library, Search } from "lucide-react-native"; 3 3 import { colors } from "@/constants/theme"; 4 4 5 5 export default function TabLayout() { ··· 38 38 name="shelf" 39 39 options={{ 40 40 title: "Shelf", 41 - tabBarIcon: ({ color, size }) => <Library size={size} color={color} />, 41 + tabBarIcon: ({ color, size }) => ( 42 + <Library size={size} color={color} /> 43 + ), 42 44 headerShown: false, 43 45 }} 44 46 />
+11 -4
apps/mobile/app/(tabs)/index.tsx
··· 1 1 import { router } from "expo-router"; 2 - import { Film, Search, Shield, Share2 } from "lucide-react-native"; 2 + import { Film, Search, Share2, Shield } from "lucide-react-native"; 3 3 import { ScrollView, StyleSheet, Text, View } from "react-native"; 4 4 import { SafeAreaView } from "react-native-safe-area-context"; 5 5 import { Button } from "@/components/ui/Button"; ··· 10 10 { 11 11 icon: Film, 12 12 title: "Track Your Media", 13 - description: "Keep track of movies, shows, and games you've watched and played", 13 + description: 14 + "Keep track of movies, shows, and games you've watched and played", 14 15 }, 15 16 { 16 17 icon: Shield, ··· 50 51 {features.map((feature, index) => ( 51 52 <Card key={index} style={styles.featureCard}> 52 53 <CardHeader> 53 - <feature.icon size={32} color={colors.primary} style={styles.featureIcon} /> 54 + <feature.icon 55 + size={32} 56 + color={colors.primary} 57 + style={styles.featureIcon} 58 + /> 54 59 <Text style={styles.featureTitle}>{feature.title}</Text> 55 60 </CardHeader> 56 61 <CardContent> 57 - <Text style={styles.featureDescription}>{feature.description}</Text> 62 + <Text style={styles.featureDescription}> 63 + {feature.description} 64 + </Text> 58 65 </CardContent> 59 66 </Card> 60 67 ))}
+53 -32
apps/mobile/app/(tabs)/search.tsx
··· 1 - import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 1 import { 3 2 moviesControllerDiscoverMoviesOptions, 4 3 moviesControllerGetUserMoviesOptions, ··· 9 8 type TmdbMovieResultDto, 10 9 } from "@opnshelf/api"; 11 10 import { FlashList, type ListRenderItem } from "@shopify/flash-list"; 11 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 12 + import * as Haptics from "expo-haptics"; 13 + import { Image } from "expo-image"; 12 14 import { router } from "expo-router"; 13 15 import { Check, Loader2, Plus } from "lucide-react-native"; 14 16 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 15 17 import { 18 + type GestureResponderEvent, 19 + Platform, 16 20 Pressable, 17 21 StyleSheet, 18 22 Text, 19 23 View, 20 - Platform, 21 24 } from "react-native"; 22 - import { SafeAreaView } from "react-native-safe-area-context"; 23 - import { useAuth } from "@/contexts/auth"; 24 - import { useToast } from "@/contexts/toast"; 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 - import * as Haptics from "expo-haptics"; 30 25 import Animated, { 26 + Easing, 31 27 useAnimatedStyle, 32 28 useSharedValue, 29 + withRepeat, 33 30 withSpring, 34 31 withTiming, 35 - withRepeat, 36 - Easing, 37 32 } from "react-native-reanimated"; 33 + import { SafeAreaView } from "react-native-safe-area-context"; 34 + import { SearchInput } from "@/components/ui/Input"; 35 + import { Skeleton } from "@/components/ui/Skeleton"; 36 + import { borderRadius, colors, spacing } from "@/constants/theme"; 37 + import { useAuth } from "@/contexts/auth"; 38 + import { useToast } from "@/contexts/toast"; 38 39 39 40 const DEBOUNCE_MS = 300; 40 41 const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w342"; ··· 64 65 rotation.value = withRepeat( 65 66 withTiming(360, { duration: 1000, easing: Easing.linear }), 66 67 -1, 67 - false 68 + false, 68 69 ); 69 70 70 71 const animatedStyle = useAnimatedStyle(() => ({ ··· 78 79 ); 79 80 }; 80 81 81 - const MovieItem = ({ movie, isWatched, isMarking, isUnmarking, onToggle, onPress }: MovieItemProps) => { 82 + const MovieItem = ({ 83 + movie, 84 + isWatched, 85 + isMarking, 86 + isUnmarking, 87 + onToggle, 88 + onPress, 89 + }: MovieItemProps) => { 82 90 const scale = useSharedValue(1); 83 91 const opacity = useSharedValue(1); 84 92 85 - const handleToggle = useCallback((e: any) => { 86 - e.stopPropagation(); 87 - 88 - if (Platform.OS !== "web") { 89 - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); 90 - } 91 - 92 - onToggle(movie.id.toString(), isWatched); 93 - }, [movie.id, isWatched, onToggle]); 93 + const handleToggle = useCallback( 94 + (e: GestureResponderEvent) => { 95 + e.stopPropagation(); 96 + 97 + if (Platform.OS !== "web") { 98 + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); 99 + } 100 + 101 + onToggle(movie.id.toString(), isWatched); 102 + }, 103 + [movie.id, isWatched, onToggle], 104 + ); 94 105 95 106 const handlePressIn = useCallback(() => { 96 107 scale.value = withSpring(0.95, { damping: 15, stiffness: 300 }); ··· 124 135 <Text style={styles.noPosterText}>No poster</Text> 125 136 </View> 126 137 )} 127 - 138 + 128 139 {/* Quick add button - icon only, top-right with expanded touch area */} 129 140 <AnimatedPressable 130 141 onPress={handleToggle} ··· 214 225 215 226 // Discover popular movies when no search query 216 227 const { data: discoverData, isLoading: isDiscoverLoading } = useQuery({ 217 - ...moviesControllerDiscoverMoviesOptions({ 218 - }), 228 + ...moviesControllerDiscoverMoviesOptions({}), 219 229 enabled: debouncedQuery.length === 0, 220 230 }); 221 231 ··· 265 275 markMutation.mutate({ body: { movieId } }); 266 276 } 267 277 }, 268 - [user, markMutation, unmarkMutation, showToast] 278 + [user, markMutation, unmarkMutation, showToast], 269 279 ); 270 280 271 281 const handleMoviePress = useCallback( ··· 278 288 }, 279 289 }); 280 290 }, 281 - [] 291 + [], 282 292 ); 283 293 284 294 const renderMovieItem: ListRenderItem<TmdbMovieResultDto> = useCallback( ··· 286 296 const movieId = item.id.toString(); 287 297 const isWatched = watchedMovieIds.has(movieId); 288 298 const isMarking = 289 - markMutation.isPending && markMutation.variables?.body?.movieId === movieId; 299 + markMutation.isPending && 300 + markMutation.variables?.body?.movieId === movieId; 290 301 const isUnmarking = 291 - unmarkMutation.isPending && unmarkMutation.variables?.path?.movieId === movieId; 302 + unmarkMutation.isPending && 303 + unmarkMutation.variables?.path?.movieId === movieId; 292 304 293 305 return ( 294 306 <MovieItem ··· 301 313 /> 302 314 ); 303 315 }, 304 - [watchedMovieIds, markMutation, unmarkMutation, handleToggleWatched, handleMoviePress] 316 + [ 317 + watchedMovieIds, 318 + markMutation, 319 + unmarkMutation, 320 + handleToggleWatched, 321 + handleMoviePress, 322 + ], 305 323 ); 306 324 307 - const keyExtractor = useCallback((item: TmdbMovieResultDto) => item.id.toString(), []); 325 + const keyExtractor = useCallback( 326 + (item: TmdbMovieResultDto) => item.id.toString(), 327 + [], 328 + ); 308 329 309 330 const renderSkeleton = () => ( 310 331 <View style={styles.skeletonGrid}>
+559 -538
apps/mobile/app/(tabs)/shelf.tsx
··· 1 - import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 1 + import type { TrackedMovieDto } from "@opnshelf/api"; 2 2 import { 3 - moviesControllerGetUserMoviesOptions, 4 - moviesControllerGetUserMoviesQueryKey, 5 - moviesControllerUnmarkWatchedMutation, 3 + moviesControllerGetUserMoviesOptions, 4 + moviesControllerGetUserMoviesQueryKey, 5 + moviesControllerUnmarkWatchedMutation, 6 6 } from "@opnshelf/api"; 7 - import type { TrackedMovieDto } from "@opnshelf/api"; 8 7 import { FlashList } from "@shopify/flash-list"; 8 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 9 + import { format } from "date-fns"; 10 + import { Image } from "expo-image"; 9 11 import { router } from "expo-router"; 10 12 import { 11 - BookOpen, 12 - Loader2, 13 - LogIn, 14 - LogOut, 15 - Settings, 16 - Trash2, 17 - CheckCircle2, 13 + BookOpen, 14 + CheckCircle2, 15 + Loader2, 16 + LogIn, 17 + LogOut, 18 + Settings, 19 + Trash2, 18 20 } from "lucide-react-native"; 19 21 import { useCallback } from "react"; 20 - import { StyleSheet, Text, View, TouchableOpacity } from "react-native"; 22 + import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 23 + import Animated, { 24 + Easing, 25 + useAnimatedStyle, 26 + useSharedValue, 27 + withRepeat, 28 + withTiming, 29 + } from "react-native-reanimated"; 21 30 import { SafeAreaView } from "react-native-safe-area-context"; 22 - import { useAuth } from "@/contexts/auth"; 23 - import { useToast } from "@/contexts/toast"; 24 31 import { Button } from "@/components/ui/Button"; 25 32 import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 26 33 import { Skeleton } from "@/components/ui/Skeleton"; 27 - import { colors, spacing, borderRadius } from "@/constants/theme"; 28 - import { Image } from "expo-image"; 29 - import { format } from "date-fns"; 30 - import Animated, { 31 - useAnimatedStyle, 32 - useSharedValue, 33 - withRepeat, 34 - withTiming, 35 - Easing, 36 - } from "react-native-reanimated"; 34 + import { borderRadius, colors, spacing } from "@/constants/theme"; 35 + import { useAuth } from "@/contexts/auth"; 36 + import { useToast } from "@/contexts/toast"; 37 37 38 38 const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w342"; 39 39 40 40 function createTitleSlug(title: string): string { 41 - return title 42 - .replace(/[^a-zA-Z0-9\s-]/g, "") 43 - .trim() 44 - .replace(/\s+/g, "-"); 41 + return title 42 + .replace(/[^a-zA-Z0-9\s-]/g, "") 43 + .trim() 44 + .replace(/\s+/g, "-"); 45 45 } 46 46 47 47 // Spinning loader component 48 48 const SpinningLoader = ({ size, color }: { size: number; color: string }) => { 49 - const rotation = useSharedValue(0); 49 + const rotation = useSharedValue(0); 50 50 51 - rotation.value = withRepeat( 52 - withTiming(360, { duration: 1000, easing: Easing.linear }), 53 - -1, 54 - false, 55 - ); 51 + rotation.value = withRepeat( 52 + withTiming(360, { duration: 1000, easing: Easing.linear }), 53 + -1, 54 + false, 55 + ); 56 56 57 - const animatedStyle = useAnimatedStyle(() => ({ 58 - transform: [{ rotate: `${rotation.value}deg` }], 59 - })); 57 + const animatedStyle = useAnimatedStyle(() => ({ 58 + transform: [{ rotate: `${rotation.value}deg` }], 59 + })); 60 60 61 - return ( 62 - <Animated.View style={animatedStyle}> 63 - <Loader2 size={size} color={color} /> 64 - </Animated.View> 65 - ); 61 + return ( 62 + <Animated.View style={animatedStyle}> 63 + <Loader2 size={size} color={color} /> 64 + </Animated.View> 65 + ); 66 66 }; 67 67 68 68 // Movie Card - Horizontal layout with poster on left 69 69 interface MovieCardProps { 70 - tracked: TrackedMovieDto; 71 - isRemoving: boolean; 72 - onRemove: (movieId: string) => void; 73 - onPress: () => void; 70 + tracked: TrackedMovieDto; 71 + isRemoving: boolean; 72 + onRemove: (movieId: string) => void; 73 + onPress: () => void; 74 74 } 75 75 76 76 const MovieCard = ({ 77 - tracked, 78 - isRemoving, 79 - onRemove, 80 - onPress, 77 + tracked, 78 + isRemoving, 79 + onRemove, 80 + onPress, 81 81 }: MovieCardProps) => { 82 - const formattedWatchedDate = tracked.watchedDate 83 - ? format(new Date(tracked.watchedDate), "MMM d, yyyy • HH:mm") 84 - : null; 82 + const formattedWatchedDate = tracked.watchedDate 83 + ? format(new Date(tracked.watchedDate), "MMM d, yyyy • HH:mm") 84 + : null; 85 85 86 - return ( 87 - <TouchableOpacity 88 - onPress={onPress} 89 - style={styles.card} 90 - activeOpacity={0.8} 91 - > 92 - {/* Poster on the left */} 93 - <View style={styles.posterContainer}> 94 - {tracked.movie.posterPath ? ( 95 - <Image 96 - source={{ uri: `${POSTER_BASE_URL}${tracked.movie.posterPath}` }} 97 - style={styles.poster} 98 - contentFit="cover" 99 - transition={200} 100 - /> 101 - ) : ( 102 - <View style={[styles.poster, styles.noPoster]}> 103 - <Text style={styles.noPosterText}>No poster</Text> 104 - </View> 105 - )} 106 - </View> 86 + return ( 87 + <TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.8}> 88 + {/* Poster on the left */} 89 + <View style={styles.posterContainer}> 90 + {tracked.movie.posterPath ? ( 91 + <Image 92 + source={{ uri: `${POSTER_BASE_URL}${tracked.movie.posterPath}` }} 93 + style={styles.poster} 94 + contentFit="cover" 95 + transition={200} 96 + /> 97 + ) : ( 98 + <View style={[styles.poster, styles.noPoster]}> 99 + <Text style={styles.noPosterText}>No poster</Text> 100 + </View> 101 + )} 102 + </View> 107 103 108 - {/* Content on the right */} 109 - <View style={styles.cardContent}> 110 - <View style={styles.info}> 111 - <Text style={styles.movieTitle} numberOfLines={2}> 112 - {tracked.movie.title} 113 - </Text> 114 - <View style={styles.meta}> 115 - {tracked.movie.releaseYear && ( 116 - <Text style={styles.year}>{tracked.movie.releaseYear}</Text> 117 - )} 118 - {formattedWatchedDate && ( 119 - <> 120 - <Text style={styles.metaDot}>•</Text> 121 - <View style={styles.watchedRow}> 122 - <CheckCircle2 size={12} color={colors.success} /> 123 - <Text style={styles.watchedDate}>{formattedWatchedDate}</Text> 124 - </View> 125 - </> 126 - )} 127 - </View> 128 - </View> 104 + {/* Content on the right */} 105 + <View style={styles.cardContent}> 106 + <View style={styles.info}> 107 + <Text style={styles.movieTitle} numberOfLines={2}> 108 + {tracked.movie.title} 109 + </Text> 110 + <View style={styles.meta}> 111 + {tracked.movie.releaseYear && ( 112 + <Text style={styles.year}>{tracked.movie.releaseYear}</Text> 113 + )} 114 + {formattedWatchedDate && ( 115 + <> 116 + <Text style={styles.metaDot}>•</Text> 117 + <View style={styles.watchedRow}> 118 + <CheckCircle2 size={12} color={colors.success} /> 119 + <Text style={styles.watchedDate}>{formattedWatchedDate}</Text> 120 + </View> 121 + </> 122 + )} 123 + </View> 124 + </View> 129 125 130 - {/* Remove button */} 131 - <TouchableOpacity 132 - onPress={(e) => { 133 - e.stopPropagation(); 134 - onRemove(tracked.movieId); 135 - }} 136 - disabled={isRemoving} 137 - style={styles.removeButton} 138 - activeOpacity={0.7} 139 - > 140 - {isRemoving ? ( 141 - <SpinningLoader size={14} color={colors.text} /> 142 - ) : ( 143 - <> 144 - <Trash2 size={14} color={colors.text} /> 145 - <Text style={styles.removeButtonText}>Remove</Text> 146 - </> 147 - )} 148 - </TouchableOpacity> 149 - </View> 150 - </TouchableOpacity> 151 - ); 126 + {/* Remove button */} 127 + <TouchableOpacity 128 + onPress={(e) => { 129 + e.stopPropagation(); 130 + onRemove(tracked.movieId); 131 + }} 132 + disabled={isRemoving} 133 + style={styles.removeButton} 134 + activeOpacity={0.7} 135 + > 136 + {isRemoving ? ( 137 + <SpinningLoader size={14} color={colors.text} /> 138 + ) : ( 139 + <> 140 + <Trash2 size={14} color={colors.text} /> 141 + <Text style={styles.removeButtonText}>Remove</Text> 142 + </> 143 + )} 144 + </TouchableOpacity> 145 + </View> 146 + </TouchableOpacity> 147 + ); 152 148 }; 153 149 154 150 export default function ShelfScreen() { 155 - const { user, isLoading: isAuthLoading, isAuthenticated, logout } = useAuth(); 156 - const { showToast } = useToast(); 157 - const queryClient = useQueryClient(); 151 + const { user, isLoading: isAuthLoading, isAuthenticated, logout } = useAuth(); 152 + const { showToast } = useToast(); 153 + const queryClient = useQueryClient(); 158 154 159 - // Fetch user's tracked movies 160 - const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 161 - ...moviesControllerGetUserMoviesOptions({ 162 - path: { userDid: user?.did || "" }, 163 - }), 164 - enabled: !!user?.did, 165 - }); 155 + // Fetch user's tracked movies 156 + const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 157 + ...moviesControllerGetUserMoviesOptions({ 158 + path: { userDid: user?.did || "" }, 159 + }), 160 + enabled: !!user?.did, 161 + }); 166 162 167 - // Remove from shelf mutation 168 - const unmarkMutation = useMutation({ 169 - ...moviesControllerUnmarkWatchedMutation(), 170 - onSuccess: () => { 171 - queryClient.invalidateQueries({ 172 - queryKey: moviesControllerGetUserMoviesQueryKey({ 173 - path: { userDid: user?.did || "" }, 174 - }), 175 - }); 176 - showToast("Removed from your shelf", "success"); 177 - }, 178 - onError: () => { 179 - showToast("Failed to remove from shelf. Please try again.", "error"); 180 - }, 181 - }); 163 + // Remove from shelf mutation 164 + const unmarkMutation = useMutation({ 165 + ...moviesControllerUnmarkWatchedMutation(), 166 + onSuccess: () => { 167 + queryClient.invalidateQueries({ 168 + queryKey: moviesControllerGetUserMoviesQueryKey({ 169 + path: { userDid: user?.did || "" }, 170 + }), 171 + }); 172 + showToast("Removed from your shelf", "success"); 173 + }, 174 + onError: () => { 175 + showToast("Failed to remove from shelf. Please try again.", "error"); 176 + }, 177 + }); 182 178 183 - const handleRemove = useCallback( 184 - (movieId: string) => { 185 - unmarkMutation.mutate({ path: { movieId } }); 186 - }, 187 - [unmarkMutation], 188 - ); 179 + const handleRemove = useCallback( 180 + (movieId: string) => { 181 + unmarkMutation.mutate({ path: { movieId } }); 182 + }, 183 + [unmarkMutation], 184 + ); 189 185 190 - const handleMoviePress = useCallback((tracked: TrackedMovieDto) => { 191 - router.push({ 192 - pathname: "/movie/[id]", 193 - params: { 194 - id: tracked.movieId, 195 - title: createTitleSlug(tracked.movie.title), 196 - }, 197 - }); 198 - }, []); 186 + const handleMoviePress = useCallback((tracked: TrackedMovieDto) => { 187 + router.push({ 188 + pathname: "/movie/[id]", 189 + params: { 190 + id: tracked.movieId, 191 + title: createTitleSlug(tracked.movie.title), 192 + }, 193 + }); 194 + }, []); 199 195 200 - const renderItem = useCallback( 201 - ({ item }: { item: TrackedMovieDto }) => { 202 - const isRemoving = 203 - unmarkMutation.isPending && 204 - unmarkMutation.variables?.path?.movieId === item.movieId; 196 + const renderItem = useCallback( 197 + ({ item }: { item: TrackedMovieDto }) => { 198 + const isRemoving = 199 + unmarkMutation.isPending && 200 + unmarkMutation.variables?.path?.movieId === item.movieId; 205 201 206 - return ( 207 - <MovieCard 208 - tracked={item} 209 - isRemoving={isRemoving} 210 - onRemove={handleRemove} 211 - onPress={() => handleMoviePress(item)} 212 - /> 213 - ); 214 - }, 215 - [unmarkMutation, handleRemove, handleMoviePress], 216 - ); 202 + return ( 203 + <MovieCard 204 + tracked={item} 205 + isRemoving={isRemoving} 206 + onRemove={handleRemove} 207 + onPress={() => handleMoviePress(item)} 208 + /> 209 + ); 210 + }, 211 + [unmarkMutation, handleRemove, handleMoviePress], 212 + ); 217 213 218 - const keyExtractor = useCallback((item: TrackedMovieDto) => item.id, []); 214 + const keyExtractor = useCallback((item: TrackedMovieDto) => item.id, []); 219 215 220 - const handleAuthAction = useCallback(async () => { 221 - if (isAuthenticated) { 222 - await logout(); 223 - showToast("Logged out successfully", "success"); 224 - } else { 225 - router.push("/login"); 226 - } 227 - }, [isAuthenticated, logout, showToast]); 216 + const handleAuthAction = useCallback(async () => { 217 + if (isAuthenticated) { 218 + await logout(); 219 + showToast("Logged out successfully", "success"); 220 + } else { 221 + router.push("/login"); 222 + } 223 + }, [isAuthenticated, logout, showToast]); 228 224 229 - // Loading state 230 - if (isAuthLoading) { 231 - return ( 232 - <SafeAreaView style={styles.container} edges={["top"]}> 233 - <View style={styles.header}> 234 - <View style={styles.headerLeft}> 235 - <BookOpen size={32} color={colors.primary} /> 236 - <Text style={styles.title}>My Shelf</Text> 237 - </View> 238 - </View> 239 - <View style={styles.skeletonContainer}> 240 - {[...Array(6)].map((_, i) => ( 241 - <View key={i} style={styles.skeleton}> 242 - <View style={[styles.skeletonPoster, { backgroundColor: colors.cardMuted }]} /> 243 - <View style={styles.skeletonContent}> 244 - <Skeleton width="70%" height={18} /> 245 - <Skeleton width="40%" height={14} style={{ marginTop: spacing.sm }} /> 246 - </View> 247 - </View> 248 - ))} 249 - </View> 250 - </SafeAreaView> 251 - ); 252 - } 225 + // Loading state 226 + if (isAuthLoading) { 227 + return ( 228 + <SafeAreaView style={styles.container} edges={["top"]}> 229 + <View style={styles.header}> 230 + <View style={styles.headerLeft}> 231 + <BookOpen size={32} color={colors.primary} /> 232 + <Text style={styles.title}>My Shelf</Text> 233 + </View> 234 + </View> 235 + <View style={styles.skeletonContainer}> 236 + {[...Array(6)].map((_, i) => ( 237 + <View key={i} style={styles.skeleton}> 238 + <View 239 + style={[ 240 + styles.skeletonPoster, 241 + { backgroundColor: colors.cardMuted }, 242 + ]} 243 + /> 244 + <View style={styles.skeletonContent}> 245 + <Skeleton width="70%" height={18} /> 246 + <Skeleton 247 + width="40%" 248 + height={14} 249 + style={{ marginTop: spacing.sm }} 250 + /> 251 + </View> 252 + </View> 253 + ))} 254 + </View> 255 + </SafeAreaView> 256 + ); 257 + } 253 258 254 - // Not authenticated state 255 - if (!isAuthenticated) { 256 - return ( 257 - <SafeAreaView style={styles.container} edges={["top"]}> 258 - <View style={styles.header}> 259 - <View style={styles.headerLeft}> 260 - <BookOpen size={32} color={colors.primary} /> 261 - <Text style={styles.title}>My Shelf</Text> 262 - </View> 263 - <TouchableOpacity onPress={handleAuthAction} style={styles.authButton}> 264 - <LogIn size={20} color={colors.text} /> 265 - <Text style={styles.authButtonText}>Sign in</Text> 266 - </TouchableOpacity> 267 - </View> 268 - <View style={styles.centerContent}> 269 - <Card style={styles.authCard}> 270 - <CardHeader style={styles.authCardHeader}> 271 - <BookOpen 272 - size={64} 273 - color={colors.primary} 274 - style={styles.authIcon} 275 - /> 276 - <Text style={styles.authTitle}>My Shelf</Text> 277 - <Text style={styles.authDescription}> 278 - Sign in to track movies you&apos;ve watched 279 - </Text> 280 - </CardHeader> 281 - <CardContent> 282 - <Button size="lg" onPress={() => router.push("/login")}> 283 - <LogIn 284 - size={20} 285 - color={colors.text} 286 - style={styles.buttonIcon} 287 - /> 288 - <Text style={styles.buttonText}>Sign in</Text> 289 - </Button> 290 - </CardContent> 291 - </Card> 292 - </View> 293 - </SafeAreaView> 294 - ); 295 - } 259 + // Not authenticated state 260 + if (!isAuthenticated) { 261 + return ( 262 + <SafeAreaView style={styles.container} edges={["top"]}> 263 + <View style={styles.header}> 264 + <View style={styles.headerLeft}> 265 + <BookOpen size={32} color={colors.primary} /> 266 + <Text style={styles.title}>My Shelf</Text> 267 + </View> 268 + <TouchableOpacity 269 + onPress={handleAuthAction} 270 + style={styles.authButton} 271 + > 272 + <LogIn size={20} color={colors.text} /> 273 + <Text style={styles.authButtonText}>Sign in</Text> 274 + </TouchableOpacity> 275 + </View> 276 + <View style={styles.centerContent}> 277 + <Card style={styles.authCard}> 278 + <CardHeader style={styles.authCardHeader}> 279 + <BookOpen 280 + size={64} 281 + color={colors.primary} 282 + style={styles.authIcon} 283 + /> 284 + <Text style={styles.authTitle}>My Shelf</Text> 285 + <Text style={styles.authDescription}> 286 + Sign in to track movies you&apos;ve watched 287 + </Text> 288 + </CardHeader> 289 + <CardContent> 290 + <Button size="lg" onPress={() => router.push("/login")}> 291 + <LogIn 292 + size={20} 293 + color={colors.text} 294 + style={styles.buttonIcon} 295 + /> 296 + <Text style={styles.buttonText}>Sign in</Text> 297 + </Button> 298 + </CardContent> 299 + </Card> 300 + </View> 301 + </SafeAreaView> 302 + ); 303 + } 296 304 297 - return ( 298 - <SafeAreaView style={styles.container} edges={["top"]}> 299 - <View style={styles.header}> 300 - <View style={styles.headerLeft}> 301 - <BookOpen size={32} color={colors.primary} /> 302 - <Text style={styles.title}>My Shelf</Text> 303 - </View> 304 - <View style={styles.headerRight}> 305 - <TouchableOpacity 306 - onPress={() => router.push("/settings")} 307 - style={styles.iconButton} 308 - > 309 - <Settings size={20} color={colors.text} /> 310 - </TouchableOpacity> 311 - <TouchableOpacity onPress={handleAuthAction} style={styles.authButton}> 312 - <LogOut size={20} color={colors.text} /> 313 - <Text style={styles.authButtonText}>Logout</Text> 314 - </TouchableOpacity> 315 - </View> 316 - </View> 305 + return ( 306 + <SafeAreaView style={styles.container} edges={["top"]}> 307 + <View style={styles.header}> 308 + <View style={styles.headerLeft}> 309 + <BookOpen size={32} color={colors.primary} /> 310 + <Text style={styles.title}>My Shelf</Text> 311 + </View> 312 + <View style={styles.headerRight}> 313 + <TouchableOpacity 314 + onPress={() => router.push("/settings")} 315 + style={styles.iconButton} 316 + > 317 + <Settings size={20} color={colors.text} /> 318 + </TouchableOpacity> 319 + <TouchableOpacity 320 + onPress={handleAuthAction} 321 + style={styles.authButton} 322 + > 323 + <LogOut size={20} color={colors.text} /> 324 + <Text style={styles.authButtonText}>Logout</Text> 325 + </TouchableOpacity> 326 + </View> 327 + </View> 317 328 318 - {isMoviesLoading && ( 319 - <View style={styles.skeletonContainer}> 320 - {[...Array(6)].map((_, i) => ( 321 - <View key={i} style={styles.skeleton}> 322 - <View style={[styles.skeletonPoster, { backgroundColor: colors.cardMuted }]} /> 323 - <View style={styles.skeletonContent}> 324 - <Skeleton width="70%" height={18} /> 325 - <Skeleton width="40%" height={14} style={{ marginTop: spacing.sm }} /> 326 - </View> 327 - </View> 328 - ))} 329 - </View> 330 - )} 329 + {isMoviesLoading && ( 330 + <View style={styles.skeletonContainer}> 331 + {[...Array(6)].map((_, i) => ( 332 + <View key={i} style={styles.skeleton}> 333 + <View 334 + style={[ 335 + styles.skeletonPoster, 336 + { backgroundColor: colors.cardMuted }, 337 + ]} 338 + /> 339 + <View style={styles.skeletonContent}> 340 + <Skeleton width="70%" height={18} /> 341 + <Skeleton 342 + width="40%" 343 + height={14} 344 + style={{ marginTop: spacing.sm }} 345 + /> 346 + </View> 347 + </View> 348 + ))} 349 + </View> 350 + )} 331 351 332 - {trackedMovies && trackedMovies.length > 0 && ( 333 - <> 334 - <Text style={styles.resultsCount}> 335 - {trackedMovies.length} movie{trackedMovies.length !== 1 ? "s" : ""} watched 336 - </Text> 337 - <FlashList 338 - data={trackedMovies} 339 - renderItem={renderItem} 340 - keyExtractor={keyExtractor} 341 - contentContainerStyle={styles.listContent} 342 - ItemSeparatorComponent={() => <View style={styles.itemSeparator} />} 343 - /> 344 - </> 345 - )} 352 + {trackedMovies && trackedMovies.length > 0 && ( 353 + <> 354 + <Text style={styles.resultsCount}> 355 + {trackedMovies.length} movie{trackedMovies.length !== 1 ? "s" : ""}{" "} 356 + watched 357 + </Text> 358 + <FlashList 359 + data={trackedMovies} 360 + renderItem={renderItem} 361 + keyExtractor={keyExtractor} 362 + contentContainerStyle={styles.listContent} 363 + ItemSeparatorComponent={() => <View style={styles.itemSeparator} />} 364 + /> 365 + </> 366 + )} 346 367 347 - {trackedMovies && trackedMovies.length === 0 && ( 348 - <View style={styles.centerContent}> 349 - <Card style={styles.emptyCard}> 350 - <CardHeader style={styles.emptyCardHeader}> 351 - <BookOpen 352 - size={64} 353 - color={colors.textSecondary} 354 - style={styles.emptyIcon} 355 - /> 356 - <Text style={styles.emptyTitle}>Your shelf is empty</Text> 357 - <Text style={styles.emptyDescription}> 358 - Start tracking movies you&apos;ve watched 359 - </Text> 360 - </CardHeader> 361 - <CardContent> 362 - <Button onPress={() => router.push("/(tabs)/search")}> 363 - <Text style={styles.buttonText}>Search for movies</Text> 364 - </Button> 365 - </CardContent> 366 - </Card> 367 - </View> 368 - )} 369 - </SafeAreaView> 370 - ); 368 + {trackedMovies && trackedMovies.length === 0 && ( 369 + <View style={styles.centerContent}> 370 + <Card style={styles.emptyCard}> 371 + <CardHeader style={styles.emptyCardHeader}> 372 + <BookOpen 373 + size={64} 374 + color={colors.textSecondary} 375 + style={styles.emptyIcon} 376 + /> 377 + <Text style={styles.emptyTitle}>Your shelf is empty</Text> 378 + <Text style={styles.emptyDescription}> 379 + Start tracking movies you&apos;ve watched 380 + </Text> 381 + </CardHeader> 382 + <CardContent> 383 + <Button onPress={() => router.push("/(tabs)/search")}> 384 + <Text style={styles.buttonText}>Search for movies</Text> 385 + </Button> 386 + </CardContent> 387 + </Card> 388 + </View> 389 + )} 390 + </SafeAreaView> 391 + ); 371 392 } 372 393 373 394 const styles = StyleSheet.create({ 374 - container: { 375 - flex: 1, 376 - backgroundColor: colors.background, 377 - }, 378 - header: { 379 - paddingHorizontal: spacing.lg, 380 - paddingVertical: spacing.md, 381 - flexDirection: "row", 382 - justifyContent: "space-between", 383 - alignItems: "center", 384 - }, 385 - headerLeft: { 386 - flexDirection: "row", 387 - alignItems: "center", 388 - gap: spacing.sm, 389 - }, 390 - title: { 391 - fontSize: 28, 392 - fontWeight: "bold", 393 - color: colors.text, 394 - }, 395 - headerRight: { 396 - flexDirection: "row", 397 - alignItems: "center", 398 - gap: spacing.sm, 399 - }, 400 - iconButton: { 401 - padding: spacing.sm, 402 - backgroundColor: colors.card, 403 - borderRadius: borderRadius.md, 404 - }, 405 - authButton: { 406 - flexDirection: "row", 407 - alignItems: "center", 408 - gap: spacing.xs, 409 - paddingHorizontal: spacing.md, 410 - paddingVertical: spacing.sm, 411 - backgroundColor: colors.card, 412 - borderRadius: borderRadius.md, 413 - }, 414 - authButtonText: { 415 - fontSize: 14, 416 - fontWeight: "600", 417 - color: colors.text, 418 - }, 419 - resultsCount: { 420 - fontSize: 14, 421 - color: colors.textMuted, 422 - marginHorizontal: spacing.lg, 423 - marginBottom: spacing.sm, 424 - }, 425 - listContent: { 426 - padding: spacing.lg, 427 - }, 428 - card: { 429 - flexDirection: "row", 430 - backgroundColor: colors.card, 431 - borderRadius: borderRadius.lg, 432 - overflow: "hidden", 433 - borderWidth: 1, 434 - borderColor: colors.border, 435 - }, 436 - posterContainer: { 437 - width: 80, 438 - aspectRatio: 2 / 3, 439 - backgroundColor: colors.cardMuted, 440 - }, 441 - poster: { 442 - width: "100%", 443 - height: "100%", 444 - }, 445 - cardContent: { 446 - flex: 1, 447 - padding: spacing.md, 448 - justifyContent: "space-between", 449 - }, 450 - info: { 451 - flex: 1, 452 - }, 453 - movieTitle: { 454 - fontSize: 16, 455 - fontWeight: "600", 456 - color: colors.text, 457 - marginBottom: spacing.xs, 458 - lineHeight: 22, 459 - }, 460 - meta: { 461 - flexDirection: "row", 462 - alignItems: "center", 463 - flexWrap: "wrap", 464 - gap: spacing.xs, 465 - }, 466 - year: { 467 - fontSize: 14, 468 - color: colors.textMuted, 469 - }, 470 - watchedRow: { 471 - flexDirection: "row", 472 - alignItems: "center", 473 - gap: spacing.xs, 474 - }, 475 - watchedDate: { 476 - fontSize: 14, 477 - color: colors.success, 478 - fontWeight: "500", 479 - }, 480 - removeButton: { 481 - flexDirection: "row", 482 - alignItems: "center", 483 - gap: spacing.xs, 484 - paddingHorizontal: spacing.md, 485 - paddingVertical: spacing.sm, 486 - backgroundColor: colors.error, 487 - borderRadius: borderRadius.full, 488 - alignSelf: "flex-start", 489 - marginTop: spacing.sm, 490 - }, 491 - removeButtonText: { 492 - color: colors.text, 493 - fontSize: 14, 494 - fontWeight: "600", 495 - }, 496 - itemSeparator: { 497 - height: spacing.md, 498 - }, 499 - metaDot: { 500 - color: colors.textSecondary, 501 - fontSize: 12, 502 - }, 503 - noPoster: { 504 - justifyContent: "center", 505 - alignItems: "center", 506 - backgroundColor: colors.cardMuted, 507 - }, 508 - noPosterText: { 509 - color: colors.textSecondary, 510 - fontSize: 12, 511 - fontWeight: "500", 512 - }, 513 - centerContent: { 514 - flex: 1, 515 - justifyContent: "center", 516 - alignItems: "center", 517 - padding: spacing.xl, 518 - }, 519 - authCard: { 520 - width: "100%", 521 - maxWidth: 400, 522 - alignItems: "center", 523 - }, 524 - authCardHeader: { 525 - alignItems: "center", 526 - }, 527 - authIcon: { 528 - marginBottom: spacing.md, 529 - }, 530 - authTitle: { 531 - fontSize: 24, 532 - fontWeight: "bold", 533 - color: colors.text, 534 - textAlign: "center", 535 - marginBottom: spacing.sm, 536 - }, 537 - authDescription: { 538 - fontSize: 16, 539 - color: colors.textMuted, 540 - textAlign: "center", 541 - }, 542 - emptyCard: { 543 - width: "100%", 544 - maxWidth: 400, 545 - alignItems: "center", 546 - }, 547 - emptyCardHeader: { 548 - alignItems: "center", 549 - }, 550 - emptyIcon: { 551 - marginBottom: spacing.md, 552 - }, 553 - emptyTitle: { 554 - fontSize: 20, 555 - fontWeight: "bold", 556 - color: colors.text, 557 - textAlign: "center", 558 - marginBottom: spacing.sm, 559 - }, 560 - emptyDescription: { 561 - fontSize: 14, 562 - color: colors.textMuted, 563 - textAlign: "center", 564 - }, 565 - buttonIcon: { 566 - marginRight: spacing.sm, 567 - }, 568 - buttonText: { 569 - color: colors.text, 570 - fontSize: 16, 571 - fontWeight: "600", 572 - }, 573 - skeletonContainer: { 574 - padding: spacing.lg, 575 - }, 576 - skeleton: { 577 - flexDirection: "row", 578 - marginBottom: spacing.md, 579 - backgroundColor: colors.card, 580 - borderRadius: borderRadius.lg, 581 - overflow: "hidden", 582 - }, 583 - skeletonPoster: { 584 - width: 80, 585 - aspectRatio: 2 / 3, 586 - }, 587 - skeletonContent: { 588 - flex: 1, 589 - padding: spacing.md, 590 - justifyContent: "center", 591 - }, 395 + container: { 396 + flex: 1, 397 + backgroundColor: colors.background, 398 + }, 399 + header: { 400 + paddingHorizontal: spacing.lg, 401 + paddingVertical: spacing.md, 402 + flexDirection: "row", 403 + justifyContent: "space-between", 404 + alignItems: "center", 405 + }, 406 + headerLeft: { 407 + flexDirection: "row", 408 + alignItems: "center", 409 + gap: spacing.sm, 410 + }, 411 + title: { 412 + fontSize: 28, 413 + fontWeight: "bold", 414 + color: colors.text, 415 + }, 416 + headerRight: { 417 + flexDirection: "row", 418 + alignItems: "center", 419 + gap: spacing.sm, 420 + }, 421 + iconButton: { 422 + padding: spacing.sm, 423 + backgroundColor: colors.card, 424 + borderRadius: borderRadius.md, 425 + }, 426 + authButton: { 427 + flexDirection: "row", 428 + alignItems: "center", 429 + gap: spacing.xs, 430 + paddingHorizontal: spacing.md, 431 + paddingVertical: spacing.sm, 432 + backgroundColor: colors.card, 433 + borderRadius: borderRadius.md, 434 + }, 435 + authButtonText: { 436 + fontSize: 14, 437 + fontWeight: "600", 438 + color: colors.text, 439 + }, 440 + resultsCount: { 441 + fontSize: 14, 442 + color: colors.textMuted, 443 + marginHorizontal: spacing.lg, 444 + marginBottom: spacing.sm, 445 + }, 446 + listContent: { 447 + padding: spacing.lg, 448 + }, 449 + card: { 450 + flexDirection: "row", 451 + backgroundColor: colors.card, 452 + borderRadius: borderRadius.lg, 453 + overflow: "hidden", 454 + borderWidth: 1, 455 + borderColor: colors.border, 456 + }, 457 + posterContainer: { 458 + width: 80, 459 + aspectRatio: 2 / 3, 460 + backgroundColor: colors.cardMuted, 461 + }, 462 + poster: { 463 + width: "100%", 464 + height: "100%", 465 + }, 466 + cardContent: { 467 + flex: 1, 468 + padding: spacing.md, 469 + justifyContent: "space-between", 470 + }, 471 + info: { 472 + flex: 1, 473 + }, 474 + movieTitle: { 475 + fontSize: 16, 476 + fontWeight: "600", 477 + color: colors.text, 478 + marginBottom: spacing.xs, 479 + lineHeight: 22, 480 + }, 481 + meta: { 482 + flexDirection: "row", 483 + alignItems: "center", 484 + flexWrap: "wrap", 485 + gap: spacing.xs, 486 + }, 487 + year: { 488 + fontSize: 14, 489 + color: colors.textMuted, 490 + }, 491 + watchedRow: { 492 + flexDirection: "row", 493 + alignItems: "center", 494 + gap: spacing.xs, 495 + }, 496 + watchedDate: { 497 + fontSize: 14, 498 + color: colors.success, 499 + fontWeight: "500", 500 + }, 501 + removeButton: { 502 + flexDirection: "row", 503 + alignItems: "center", 504 + gap: spacing.xs, 505 + paddingHorizontal: spacing.md, 506 + paddingVertical: spacing.sm, 507 + backgroundColor: colors.error, 508 + borderRadius: borderRadius.full, 509 + alignSelf: "flex-start", 510 + marginTop: spacing.sm, 511 + }, 512 + removeButtonText: { 513 + color: colors.text, 514 + fontSize: 14, 515 + fontWeight: "600", 516 + }, 517 + itemSeparator: { 518 + height: spacing.md, 519 + }, 520 + metaDot: { 521 + color: colors.textSecondary, 522 + fontSize: 12, 523 + }, 524 + noPoster: { 525 + justifyContent: "center", 526 + alignItems: "center", 527 + backgroundColor: colors.cardMuted, 528 + }, 529 + noPosterText: { 530 + color: colors.textSecondary, 531 + fontSize: 12, 532 + fontWeight: "500", 533 + }, 534 + centerContent: { 535 + flex: 1, 536 + justifyContent: "center", 537 + alignItems: "center", 538 + padding: spacing.xl, 539 + }, 540 + authCard: { 541 + width: "100%", 542 + maxWidth: 400, 543 + alignItems: "center", 544 + }, 545 + authCardHeader: { 546 + alignItems: "center", 547 + }, 548 + authIcon: { 549 + marginBottom: spacing.md, 550 + }, 551 + authTitle: { 552 + fontSize: 24, 553 + fontWeight: "bold", 554 + color: colors.text, 555 + textAlign: "center", 556 + marginBottom: spacing.sm, 557 + }, 558 + authDescription: { 559 + fontSize: 16, 560 + color: colors.textMuted, 561 + textAlign: "center", 562 + }, 563 + emptyCard: { 564 + width: "100%", 565 + maxWidth: 400, 566 + alignItems: "center", 567 + }, 568 + emptyCardHeader: { 569 + alignItems: "center", 570 + }, 571 + emptyIcon: { 572 + marginBottom: spacing.md, 573 + }, 574 + emptyTitle: { 575 + fontSize: 20, 576 + fontWeight: "bold", 577 + color: colors.text, 578 + textAlign: "center", 579 + marginBottom: spacing.sm, 580 + }, 581 + emptyDescription: { 582 + fontSize: 14, 583 + color: colors.textMuted, 584 + textAlign: "center", 585 + }, 586 + buttonIcon: { 587 + marginRight: spacing.sm, 588 + }, 589 + buttonText: { 590 + color: colors.text, 591 + fontSize: 16, 592 + fontWeight: "600", 593 + }, 594 + skeletonContainer: { 595 + padding: spacing.lg, 596 + }, 597 + skeleton: { 598 + flexDirection: "row", 599 + marginBottom: spacing.md, 600 + backgroundColor: colors.card, 601 + borderRadius: borderRadius.lg, 602 + overflow: "hidden", 603 + }, 604 + skeletonPoster: { 605 + width: 80, 606 + aspectRatio: 2 / 3, 607 + }, 608 + skeletonContent: { 609 + flex: 1, 610 + padding: spacing.md, 611 + justifyContent: "center", 612 + }, 592 613 });
+2 -2
apps/mobile/app/_layout.tsx
··· 2 2 import { Stack } from "expo-router"; 3 3 import { StatusBar } from "expo-status-bar"; 4 4 import { useEffect } from "react"; 5 + import { LoadingScreen } from "@/components/LoadingScreen"; 6 + import { colors } from "@/constants/theme"; 5 7 import { AuthProvider, useAuth } from "@/contexts/auth"; 6 8 import { ToastProvider } from "@/contexts/toast"; 7 9 import { initializeApiClient } from "@/lib/api"; 8 10 import { queryClient } from "@/lib/query-client"; 9 - import { colors } from "@/constants/theme"; 10 - import { LoadingScreen } from "@/components/LoadingScreen"; 11 11 12 12 function AppContent() { 13 13 const { isLoading } = useAuth();
+1 -1
apps/mobile/app/auth/callback.tsx
··· 1 1 import { useLocalSearchParams, useRouter } from "expo-router"; 2 2 import { useEffect } from "react"; 3 3 import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; 4 - import { useAuth } from "@/contexts/auth"; 5 4 import { colors } from "@/constants/theme"; 5 + import { useAuth } from "@/contexts/auth"; 6 6 7 7 export default function AuthCallbackScreen() { 8 8 const { token } = useLocalSearchParams<{ token?: string }>();
+9 -5
apps/mobile/app/auth/complete.tsx
··· 1 - import { useEffect } from "react"; 2 - import { useQueryClient } from "@tanstack/react-query"; 3 1 import { Ionicons } from "@expo/vector-icons"; 4 - import { ActivityIndicator, Text, View } from "react-native"; 5 - import { useRouter, useLocalSearchParams } from "expo-router"; 6 2 import { authControllerMeOptions } from "@opnshelf/api"; 3 + import { useQueryClient } from "@tanstack/react-query"; 4 + import { useLocalSearchParams, useRouter } from "expo-router"; 5 + import { useEffect } from "react"; 6 + import { ActivityIndicator, Text, View } from "react-native"; 7 7 import { saveSessionToken } from "@/lib/api"; 8 8 9 9 export default function AuthCompleteScreen() { ··· 44 44 }} 45 45 > 46 46 <Ionicons name="film" size={48} color="#a855f7" /> 47 - <ActivityIndicator size="large" color="#a855f7" style={{ marginVertical: 16 }} /> 47 + <ActivityIndicator 48 + size="large" 49 + color="#a855f7" 50 + style={{ marginVertical: 16 }} 51 + /> 48 52 <Text style={{ color: "#9ca3af" }}>Completing sign-in...</Text> 49 53 </View> 50 54 );
+27 -8
apps/mobile/app/login.tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 2 + import { getLoginUrl } from "@opnshelf/api"; 3 + import { useLocalSearchParams, useRouter } from "expo-router"; 4 + import * as WebBrowser from "expo-web-browser"; 1 5 import { useEffect, useState } from "react"; 2 - import { Ionicons } from "@expo/vector-icons"; 3 6 import { 4 7 ActivityIndicator, 5 8 KeyboardAvoidingView, ··· 10 13 TouchableOpacity, 11 14 View, 12 15 } from "react-native"; 13 - import * as WebBrowser from "expo-web-browser"; 14 - import { useRouter, useLocalSearchParams } from "expo-router"; 15 - import { getLoginUrl } from "@opnshelf/api"; 16 16 import { useAuth } from "@/contexts/auth"; 17 17 18 18 export default function LoginScreen() { ··· 48 48 49 49 const result = await WebBrowser.openAuthSessionAsync( 50 50 loginUrl, 51 - "opnshelf://auth/callback" 51 + "opnshelf://auth/callback", 52 52 ); 53 53 54 54 if (result.type === "success") { ··· 73 73 74 74 if (isAuthLoading) { 75 75 return ( 76 - <View style={{ flex: 1, backgroundColor: "#030712", justifyContent: "center", alignItems: "center" }}> 76 + <View 77 + style={{ 78 + flex: 1, 79 + backgroundColor: "#030712", 80 + justifyContent: "center", 81 + alignItems: "center", 82 + }} 83 + > 77 84 <ActivityIndicator size="large" color="#a855f7" /> 78 85 </View> 79 86 ); ··· 227 234 {isSubmitting ? ( 228 235 <> 229 236 <ActivityIndicator size="small" color="#fff" /> 230 - <Text style={{ color: "#ffffff", fontWeight: "600", fontSize: 16 }}> 237 + <Text 238 + style={{ 239 + color: "#ffffff", 240 + fontWeight: "600", 241 + fontSize: 16, 242 + }} 243 + > 231 244 Redirecting... 232 245 </Text> 233 246 </> 234 247 ) : ( 235 248 <> 236 249 <Ionicons name="log-in" size={20} color="#fff" /> 237 - <Text style={{ color: "#ffffff", fontWeight: "600", fontSize: 16 }}> 250 + <Text 251 + style={{ 252 + color: "#ffffff", 253 + fontWeight: "600", 254 + fontSize: 16, 255 + }} 256 + > 238 257 Sign in 239 258 </Text> 240 259 </>
+222 -124
apps/mobile/app/movie/[id].tsx
··· 1 - import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 - import { useMemo, useState, useCallback } from "react"; 1 + import { Ionicons } from "@expo/vector-icons"; 2 + import type { 3 + TmdbCastDto, 4 + TmdbCrewDto, 5 + TmdbMovieDetailDto, 6 + WatchHistoryItemDto, 7 + } from "@opnshelf/api"; 3 8 import { 4 9 moviesControllerDeleteWatchHistoryEntryMutation, 5 10 moviesControllerGetMovieDetailsOptions, ··· 9 14 moviesControllerMarkWatchedMutation, 10 15 moviesControllerUnmarkWatchedMutation, 11 16 } from "@opnshelf/api"; 12 - import type { TmdbCastDto, TmdbCrewDto, TmdbMovieDetailDto, WatchHistoryItemDto } from "@opnshelf/api"; 13 - import DateTimePicker, { DateTimePickerEvent } from "@react-native-community/datetimepicker"; 14 - import { useLocalSearchParams, useRouter } from "expo-router"; 17 + import DateTimePicker, { 18 + type DateTimePickerEvent, 19 + } from "@react-native-community/datetimepicker"; 20 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 15 21 import { Image } from "expo-image"; 16 22 import { LinearGradient } from "expo-linear-gradient"; 17 - import { Ionicons } from "@expo/vector-icons"; 23 + import { useLocalSearchParams, useRouter } from "expo-router"; 24 + import { useCallback, useMemo, useState } from "react"; 18 25 import { 19 26 ActivityIndicator, 20 27 Modal, ··· 27 34 View, 28 35 } from "react-native"; 29 36 import { SafeAreaView } from "react-native-safe-area-context"; 37 + import { Badge } from "@/components/ui/Badge"; 38 + import { Button } from "@/components/ui/Button"; 39 + import { borderRadius, colors } from "@/constants/theme"; 30 40 import { useAuth } from "@/contexts/auth"; 31 41 import { useToast } from "@/contexts/toast"; 32 - import { Badge } from "@/components/ui/Badge"; 33 - import { Button } from "@/components/ui/Button"; 34 - import { colors, borderRadius } from "@/constants/theme"; 35 42 36 43 const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w500"; 37 44 const BACKDROP_BASE_URL = "https://image.tmdb.org/t/p/w1280"; ··· 56 63 } 57 64 58 65 export default function MovieDetailScreen() { 59 - const { id: movieId, title } = useLocalSearchParams<{ id: string; title?: string }>(); 66 + const { id: movieId, title } = useLocalSearchParams<{ 67 + id: string; 68 + title?: string; 69 + }>(); 60 70 const router = useRouter(); 61 71 const { user } = useAuth(); 62 72 const { showToast } = useToast(); ··· 203 213 (trackedMovieId: string) => { 204 214 deleteWatchEntryMutation.mutate({ path: { trackedMovieId } }); 205 215 }, 206 - [deleteWatchEntryMutation] 216 + [deleteWatchEntryMutation], 207 217 ); 208 218 209 219 const handleShare = useCallback(async () => { ··· 217 227 } 218 228 }, [movieId, title, showToast]); 219 229 220 - const onDateChange = useCallback((event: DateTimePickerEvent, selectedDate?: Date) => { 221 - setShowDatePicker(false); 222 - if (selectedDate) { 223 - const newDate = new Date(customDate); 224 - newDate.setFullYear(selectedDate.getFullYear()); 225 - newDate.setMonth(selectedDate.getMonth()); 226 - newDate.setDate(selectedDate.getDate()); 227 - setCustomDate(newDate); 228 - setShowTimePicker(true); 229 - } 230 - }, [customDate]); 230 + const onDateChange = useCallback( 231 + (_event: DateTimePickerEvent, selectedDate?: Date) => { 232 + setShowDatePicker(false); 233 + if (selectedDate) { 234 + const newDate = new Date(customDate); 235 + newDate.setFullYear(selectedDate.getFullYear()); 236 + newDate.setMonth(selectedDate.getMonth()); 237 + newDate.setDate(selectedDate.getDate()); 238 + setCustomDate(newDate); 239 + setShowTimePicker(true); 240 + } 241 + }, 242 + [customDate], 243 + ); 231 244 232 - const onTimeChange = useCallback((event: DateTimePickerEvent, selectedTime?: Date) => { 233 - setShowTimePicker(false); 234 - if (selectedTime) { 235 - const newDate = new Date(customDate); 236 - newDate.setHours(selectedTime.getHours()); 237 - newDate.setMinutes(selectedTime.getMinutes()); 238 - setCustomDate(newDate); 239 - } 240 - }, [customDate]); 245 + const onTimeChange = useCallback( 246 + (_event: DateTimePickerEvent, selectedTime?: Date) => { 247 + setShowTimePicker(false); 248 + if (selectedTime) { 249 + const newDate = new Date(customDate); 250 + newDate.setHours(selectedTime.getHours()); 251 + newDate.setMinutes(selectedTime.getMinutes()); 252 + setCustomDate(newDate); 253 + } 254 + }, 255 + [customDate], 256 + ); 241 257 242 258 const openDateModal = useCallback(() => { 243 259 setCustomDate(new Date()); ··· 271 287 272 288 return ( 273 289 <> 274 - <ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}> 290 + <ScrollView 291 + style={styles.container} 292 + contentContainerStyle={styles.scrollContent} 293 + > 275 294 {/* Hero Section with Backdrop */} 276 295 <View style={styles.heroWrapper}> 277 296 {backdropUrl ? ( ··· 281 300 contentFit="cover" 282 301 /> 283 302 ) : ( 284 - <View style={[styles.backdrop, { backgroundColor: movieColors.muted }]} /> 303 + <View 304 + style={[styles.backdrop, { backgroundColor: movieColors.muted }]} 305 + /> 285 306 )} 286 307 287 308 {/* Back button */} ··· 321 342 <View style={styles.metaRow}> 322 343 {!!releaseYear && ( 323 344 <View style={styles.metaItem}> 324 - <Ionicons name="calendar-outline" size={14} color={movieColors.accent} /> 345 + <Ionicons 346 + name="calendar-outline" 347 + size={14} 348 + color={movieColors.accent} 349 + /> 325 350 <Text style={styles.metaText}>{releaseYear}</Text> 326 351 </View> 327 352 )} ··· 331 356 style={styles.metaItem} 332 357 activeOpacity={0.8} 333 358 > 334 - <Ionicons name="time-outline" size={14} color={movieColors.accent} /> 359 + <Ionicons 360 + name="time-outline" 361 + size={14} 362 + color={movieColors.accent} 363 + /> 335 364 <Text style={styles.metaText}> 336 365 {formatRuntime(movie.runtime, showHours)} 337 366 </Text> ··· 354 383 disabled={isPending} 355 384 style={[ 356 385 styles.primaryButton, 357 - { backgroundColor: movieColors.primary, opacity: isPending ? 0.7 : 1 }, 386 + { 387 + backgroundColor: movieColors.primary, 388 + opacity: isPending ? 0.7 : 1, 389 + }, 358 390 ]} 359 391 activeOpacity={0.8} 360 392 > ··· 368 400 )} 369 401 </TouchableOpacity> 370 402 371 - <TouchableOpacity 372 - onPress={openDateModal} 373 - style={styles.secondaryButton} 374 - activeOpacity={0.8} 375 - > 376 - <View style={styles.buttonContent}> 377 - <Ionicons name="calendar" size={18} color="#9ca3af" /> 378 - <Text style={styles.secondaryButtonText}> 379 - Add on Different Date 380 - </Text> 381 - </View> 382 - </TouchableOpacity> 383 - <TouchableOpacity 384 - onPress={handleShare} 385 - style={styles.secondaryButton} 386 - activeOpacity={0.8} 387 - > 388 - <View style={styles.buttonContent}> 389 - <Ionicons name="share-outline" size={18} color="#9ca3af" /> 390 - <Text style={styles.secondaryButtonText}>Share</Text> 391 - </View> 392 - </TouchableOpacity> 393 - </> 394 - ) : ( 395 - <> 396 - <TouchableOpacity 397 - onPress={handleMarkWatched} 398 - disabled={isPending} 399 - style={[ 400 - styles.primaryButton, 401 - { backgroundColor: movieColors.primary, opacity: isPending ? 0.7 : 1 }, 402 - ]} 403 - activeOpacity={0.8} 404 - > 405 - {isPending ? ( 406 - <ActivityIndicator color="#f9fafb" /> 407 - ) : ( 403 + <TouchableOpacity 404 + onPress={openDateModal} 405 + style={styles.secondaryButton} 406 + activeOpacity={0.8} 407 + > 408 + <View style={styles.buttonContent}> 409 + <Ionicons name="calendar" size={18} color="#9ca3af" /> 410 + <Text style={styles.secondaryButtonText}> 411 + Add on Different Date 412 + </Text> 413 + </View> 414 + </TouchableOpacity> 415 + <TouchableOpacity 416 + onPress={handleShare} 417 + style={styles.secondaryButton} 418 + activeOpacity={0.8} 419 + > 408 420 <View style={styles.buttonContent}> 409 - <Ionicons name="refresh" size={20} color="#f9fafb" /> 410 - <Text style={styles.buttonText}>Watch Now</Text> 421 + <Ionicons 422 + name="share-outline" 423 + size={18} 424 + color="#9ca3af" 425 + /> 426 + <Text style={styles.secondaryButtonText}>Share</Text> 411 427 </View> 412 - )} 413 - </TouchableOpacity> 428 + </TouchableOpacity> 429 + </> 430 + ) : ( 431 + <> 432 + <TouchableOpacity 433 + onPress={handleMarkWatched} 434 + disabled={isPending} 435 + style={[ 436 + styles.primaryButton, 437 + { 438 + backgroundColor: movieColors.primary, 439 + opacity: isPending ? 0.7 : 1, 440 + }, 441 + ]} 442 + activeOpacity={0.8} 443 + > 444 + {isPending ? ( 445 + <ActivityIndicator color="#f9fafb" /> 446 + ) : ( 447 + <View style={styles.buttonContent}> 448 + <Ionicons name="refresh" size={20} color="#f9fafb" /> 449 + <Text style={styles.buttonText}>Watch Now</Text> 450 + </View> 451 + )} 452 + </TouchableOpacity> 414 453 415 - <TouchableOpacity 416 - onPress={openDateModal} 417 - style={styles.secondaryButton} 418 - activeOpacity={0.8} 419 - > 420 - <View style={styles.buttonContent}> 421 - <Ionicons name="calendar" size={18} color="#9ca3af" /> 422 - <Text style={styles.secondaryButtonText}> 423 - Watch on Different Date 424 - </Text> 425 - </View> 426 - </TouchableOpacity> 427 - <TouchableOpacity 428 - onPress={handleShare} 429 - style={styles.secondaryButton} 430 - activeOpacity={0.8} 431 - > 432 - <View style={styles.buttonContent}> 433 - <Ionicons name="share-outline" size={18} color="#9ca3af" /> 434 - <Text style={styles.secondaryButtonText}>Share</Text> 435 - </View> 436 - </TouchableOpacity> 437 - </> 438 - ) 454 + <TouchableOpacity 455 + onPress={openDateModal} 456 + style={styles.secondaryButton} 457 + activeOpacity={0.8} 458 + > 459 + <View style={styles.buttonContent}> 460 + <Ionicons name="calendar" size={18} color="#9ca3af" /> 461 + <Text style={styles.secondaryButtonText}> 462 + Watch on Different Date 463 + </Text> 464 + </View> 465 + </TouchableOpacity> 466 + <TouchableOpacity 467 + onPress={handleShare} 468 + style={styles.secondaryButton} 469 + activeOpacity={0.8} 470 + > 471 + <View style={styles.buttonContent}> 472 + <Ionicons 473 + name="share-outline" 474 + size={18} 475 + color="#9ca3af" 476 + /> 477 + <Text style={styles.secondaryButtonText}>Share</Text> 478 + </View> 479 + </TouchableOpacity> 480 + </> 481 + ) 439 482 ) : ( 440 483 <TouchableOpacity 441 484 onPress={() => router.push("/login")} 442 - style={[styles.primaryButton, { backgroundColor: movieColors.primary }]} 485 + style={[ 486 + styles.primaryButton, 487 + { backgroundColor: movieColors.primary }, 488 + ]} 443 489 activeOpacity={0.8} 444 490 > 445 491 <Text style={styles.buttonText}>Sign in to Track</Text> ··· 460 506 Watched on {formattedWatchedDate} 461 507 </Text> 462 508 {watchHistory && watchHistory.length > 1 && ( 463 - <Badge variant="secondary">{watchHistory.length} watches</Badge> 509 + <Badge variant="secondary"> 510 + {watchHistory.length} watches 511 + </Badge> 464 512 )} 465 513 </View> 466 514 )} ··· 485 533 <ActivityIndicator size="small" color="#ef4444" /> 486 534 ) : ( 487 535 <> 488 - <Ionicons name="trash-outline" size={16} color="#ef4444" /> 536 + <Ionicons 537 + name="trash-outline" 538 + size={16} 539 + color="#ef4444" 540 + /> 489 541 <Text style={styles.removeText}>Remove from shelf</Text> 490 542 </> 491 543 )} ··· 497 549 {/* Overview */} 498 550 {movie?.overview && ( 499 551 <View style={styles.section}> 500 - <Text style={[styles.sectionTitle, { color: movieColors.primary }]}> 552 + <Text 553 + style={[styles.sectionTitle, { color: movieColors.primary }]} 554 + > 501 555 Overview 502 556 </Text> 503 557 <Text style={styles.overview}>{movie.overview}</Text> ··· 551 605 {/* Genres */} 552 606 {movie?.genres && movie.genres.length > 0 && ( 553 607 <View style={styles.section}> 554 - <Text style={[styles.sectionTitle, { color: movieColors.primary }]}> 608 + <Text 609 + style={[styles.sectionTitle, { color: movieColors.primary }]} 610 + > 555 611 Genres 556 612 </Text> 557 613 <View style={styles.genresContainer}> ··· 566 622 }, 567 623 ]} 568 624 > 569 - <Text style={[styles.genreText, { color: movieColors.accent }]}> 625 + <Text 626 + style={[styles.genreText, { color: movieColors.accent }]} 627 + > 570 628 {genre.name} 571 629 </Text> 572 630 </View> ··· 578 636 {/* Cast */} 579 637 {movie?.credits?.cast && movie.credits.cast.length > 0 && ( 580 638 <View style={styles.section}> 581 - <Text style={[styles.sectionTitle, { color: movieColors.primary }]}> 639 + <Text 640 + style={[styles.sectionTitle, { color: movieColors.primary }]} 641 + > 582 642 Cast 583 643 </Text> 584 644 <View style={styles.castContainer}> ··· 596 656 <View style={styles.castImageContainer}> 597 657 {person.profile_path ? ( 598 658 <Image 599 - source={{ uri: `https://image.tmdb.org/t/p/w185${person.profile_path}` }} 659 + source={{ 660 + uri: `https://image.tmdb.org/t/p/w185${person.profile_path}`, 661 + }} 600 662 style={styles.castImage} 601 663 contentFit="cover" 602 664 /> 603 665 ) : ( 604 666 <View style={styles.castImagePlaceholder}> 605 - <Text style={styles.castImagePlaceholderText}>No photo</Text> 667 + <Text style={styles.castImagePlaceholderText}> 668 + No photo 669 + </Text> 606 670 </View> 607 671 )} 608 672 </View> ··· 610 674 {person.name} 611 675 </Text> 612 676 {person.character && ( 613 - <Text style={[styles.castCharacter, { color: movieColors.muted }]}> 677 + <Text 678 + style={[ 679 + styles.castCharacter, 680 + { color: movieColors.muted }, 681 + ]} 682 + > 614 683 as {person.character} 615 684 </Text> 616 685 )} ··· 630 699 {/* Crew */} 631 700 {movie?.credits?.crew && movie.credits.crew.length > 0 && ( 632 701 <View style={styles.section}> 633 - <Text style={[styles.sectionTitle, { color: movieColors.primary }]}> 702 + <Text 703 + style={[styles.sectionTitle, { color: movieColors.primary }]} 704 + > 634 705 Crew 635 706 </Text> 636 707 <View style={styles.crewGrid}> ··· 643 714 <Text style={styles.crewName} numberOfLines={1}> 644 715 {person.name} 645 716 </Text> 646 - <Text style={[styles.crewJob, { color: movieColors.muted }]}> 717 + <Text 718 + style={[styles.crewJob, { color: movieColors.muted }]} 719 + > 647 720 {person.job} 648 721 </Text> 649 722 </TouchableOpacity> ··· 669 742 <Ionicons name="close" size={24} color={colors.text} /> 670 743 </Pressable> 671 744 </View> 672 - <Text style={styles.modalDescription}>When did you watch this movie?</Text> 745 + <Text style={styles.modalDescription}> 746 + When did you watch this movie? 747 + </Text> 673 748 674 749 <View style={styles.dateTimeContainer}> 675 750 <TouchableOpacity ··· 677 752 style={styles.dateTimeButton} 678 753 activeOpacity={0.7} 679 754 > 680 - <Ionicons name="calendar-outline" size={20} color={colors.textMuted} /> 755 + <Ionicons 756 + name="calendar-outline" 757 + size={20} 758 + color={colors.textMuted} 759 + /> 681 760 <Text style={styles.dateTimeText}> 682 761 {customDate.toLocaleDateString("en-US", { 683 762 year: "numeric", ··· 691 770 style={styles.dateTimeButton} 692 771 activeOpacity={0.7} 693 772 > 694 - <Ionicons name="time-outline" size={20} color={colors.textMuted} /> 773 + <Ionicons 774 + name="time-outline" 775 + size={20} 776 + color={colors.textMuted} 777 + /> 695 778 <Text style={styles.dateTimeText}> 696 779 {customDate.toLocaleTimeString("en-US", { 697 780 hour: "2-digit", ··· 704 787 705 788 {/* Date/Time Pickers inside modal */} 706 789 {showDatePicker && ( 707 - <DateTimePicker value={customDate} mode="date" onChange={onDateChange} /> 790 + <DateTimePicker 791 + value={customDate} 792 + mode="date" 793 + onChange={onDateChange} 794 + /> 708 795 )} 709 796 {showTimePicker && ( 710 797 <DateTimePicker ··· 757 844 {watchHistory && watchHistory.length > 0 ? ( 758 845 watchHistory.map((watch) => ( 759 846 <View key={watch.id} style={styles.historyItem}> 760 - <Text style={styles.historyDate}>{formatWatchDate(watch.watchedDate)}</Text> 847 + <Text style={styles.historyDate}> 848 + {formatWatchDate(watch.watchedDate)} 849 + </Text> 761 850 <TouchableOpacity 762 851 onPress={() => handleDeleteWatchEntry(watch.id)} 763 852 disabled={deleteWatchEntryMutation.isPending} ··· 765 854 activeOpacity={0.7} 766 855 > 767 856 {deleteWatchEntryMutation.isPending && 768 - deleteWatchEntryMutation.variables?.path?.trackedMovieId === 769 - watch.id ? ( 770 - <ActivityIndicator size="small" color={colors.textMuted} /> 857 + deleteWatchEntryMutation.variables?.path 858 + ?.trackedMovieId === watch.id ? ( 859 + <ActivityIndicator 860 + size="small" 861 + color={colors.textMuted} 862 + /> 771 863 ) : ( 772 - <Ionicons name="trash-outline" size={18} color="#ef4444" /> 864 + <Ionicons 865 + name="trash-outline" 866 + size={18} 867 + color="#ef4444" 868 + /> 773 869 )} 774 870 </TouchableOpacity> 775 871 </View> ··· 779 875 )} 780 876 </ScrollView> 781 877 782 - <Button variant="outline" onPress={() => setShowHistoryModal(false)}> 878 + <Button 879 + variant="outline" 880 + onPress={() => setShowHistoryModal(false)} 881 + > 783 882 <Text style={styles.secondaryButtonText}>Close</Text> 784 883 </Button> 785 884 </View> 786 885 </View> 787 886 </Modal> 788 - 789 887 </> 790 888 ); 791 889 }
+12 -6
apps/mobile/app/settings.tsx
··· 3 3 usersControllerUpdateMySettingsMutation, 4 4 } from "@opnshelf/api"; 5 5 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 6 - import { useLocalSearchParams, useRouter } from "expo-router"; 7 - import { Globe, Clock, ChevronRight, Loader2 } from "lucide-react-native"; 6 + import { useRouter } from "expo-router"; 7 + import { ChevronRight, Clock, Globe, Loader2 } from "lucide-react-native"; 8 8 import { useCallback, useEffect, useState } from "react"; 9 9 import { 10 10 Modal, ··· 19 19 import { Button } from "@/components/ui/Button"; 20 20 import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 21 21 import { Switch } from "@/components/ui/Switch"; 22 + import { borderRadius, colors, spacing } from "@/constants/theme"; 22 23 import { useToast } from "@/contexts/toast"; 23 - import { colors, spacing, borderRadius } from "@/constants/theme"; 24 24 25 25 // Common timezones grouped by region 26 26 const TIMEZONES = [ ··· 106 106 ); 107 107 108 108 export default function SettingsScreen() { 109 - const router = useRouter(); 109 + const _router = useRouter(); 110 110 const { showToast } = useToast(); 111 111 const queryClient = useQueryClient(); 112 112 const [showTimezoneModal, setShowTimezoneModal] = useState(false); ··· 239 239 )} 240 240 </View> 241 241 {updateSettingsMutation.isPending && ( 242 - <Loader2 size={16} color={colors.warning} style={styles.spinner} /> 242 + <Loader2 243 + size={16} 244 + color={colors.warning} 245 + style={styles.spinner} 246 + /> 243 247 )} 244 248 <ChevronRight size={20} color={colors.textMuted} /> 245 249 </Pressable> ··· 284 288 <View style={styles.previewContent}> 285 289 <Clock size={20} color={colors.warning} /> 286 290 <View> 287 - <Text style={styles.previewLabel}>Current time preview</Text> 291 + <Text style={styles.previewLabel}> 292 + Current time preview 293 + </Text> 288 294 <Text style={styles.previewValue}> 289 295 {getCurrentTimeDisplay()} 290 296 </Text>
+40
apps/mobile/biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", 3 + "vcs": { 4 + "enabled": false, 5 + "clientKind": "git", 6 + "useIgnoreFile": false 7 + }, 8 + "files": { 9 + "ignoreUnknown": true, 10 + "includes": [ 11 + "app/**/*", 12 + "*.ts", 13 + "*.tsx", 14 + "babel.config.js", 15 + "metro.config.js" 16 + ], 17 + "experimentalScannerIgnores": ["node_modules", ".expo", "dist", ".git"] 18 + }, 19 + "formatter": { 20 + "enabled": true, 21 + "indentStyle": "tab" 22 + }, 23 + "assist": { "actions": { "source": { "organizeImports": "on" } } }, 24 + "linter": { 25 + "enabled": true, 26 + "rules": { 27 + "recommended": true, 28 + "suspicious": { 29 + "noArrayIndexKey": "off", 30 + "noExplicitAny": "warn" 31 + } 32 + } 33 + }, 34 + "javascript": { 35 + "jsxRuntime": "reactClassic", 36 + "formatter": { 37 + "quoteStyle": "double" 38 + } 39 + } 40 + }
-10
apps/mobile/eslint.config.js
··· 1 - // https://docs.expo.dev/guides/using-eslint/ 2 - const { defineConfig } = require('eslint/config'); 3 - const expoConfig = require('eslint-config-expo/flat'); 4 - 5 - module.exports = defineConfig([ 6 - expoConfig, 7 - { 8 - ignores: ['dist/*'], 9 - }, 10 - ]);
+4 -3
apps/mobile/package.json
··· 8 8 "android": "expo run:android", 9 9 "ios": "expo run:ios", 10 10 "web": "expo start --web", 11 - "lint": "expo lint", 11 + "lint": "biome check .", 12 + "format": "biome format .", 13 + "check": "biome check .", 12 14 "typecheck": "tsc --noEmit" 13 15 }, 14 16 "dependencies": { ··· 49 51 "react-native-worklets": "0.5.1" 50 52 }, 51 53 "devDependencies": { 54 + "@biomejs/biome": "2.2.4", 52 55 "@types/react": "~19.1.0", 53 - "eslint": "^9.25.0", 54 - "eslint-config-expo": "~10.0.0", 55 56 "typescript": "~5.9.2" 56 57 }, 57 58 "private": true
+3
pnpm-lock.yaml
··· 120 120 specifier: 0.5.1 121 121 version: 0.5.1(@babel/core@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) 122 122 devDependencies: 123 + '@biomejs/biome': 124 + specifier: 2.2.4 125 + version: 2.2.4 123 126 '@types/react': 124 127 specifier: ~19.1.0 125 128 version: 19.1.17