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: implement authentication flow and toast notifications

- Added a new LoginScreen component for user authentication.
- Introduced ToastProvider and useToast hook for displaying notifications across the app.
- Updated RootLayout to include ToastProvider.
- Replaced Alert prompts with toast notifications in SearchScreen, ShelfScreen, and MovieDetailScreen for better user experience.
- Created AuthCompleteScreen to handle post-authentication actions.
- Enhanced the layout and styling of various components for improved usability.

+931 -398
+28 -14
apps/mobile/app/(tabs)/search.tsx
··· 13 13 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 14 14 import { 15 15 ActivityIndicator, 16 - Alert, 17 16 Pressable, 18 17 StyleSheet, 19 18 Text, ··· 21 20 } from "react-native"; 22 21 import { SafeAreaView } from "react-native-safe-area-context"; 23 22 import { useAuth } from "@/contexts/auth"; 23 + import { useToast } from "@/contexts/toast"; 24 24 import { Badge } from "@/components/ui/Badge"; 25 25 import { SearchInput } from "@/components/ui/Input"; 26 26 import { Skeleton } from "@/components/ui/Skeleton"; ··· 105 105 const [debouncedQuery, setDebouncedQuery] = useState(""); 106 106 const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 107 107 const { user } = useAuth(); 108 + const { showToast } = useToast(); 108 109 const queryClient = useQueryClient(); 109 110 110 111 // Debounce search query ··· 155 156 path: { userDid: user?.did || "" }, 156 157 }), 157 158 }); 158 - Alert.alert("Success", "Added to your shelf"); 159 + showToast("Added to your shelf", "success"); 159 160 }, 160 161 onError: () => { 161 - Alert.alert("Error", "Failed to add to shelf. Please try again."); 162 + showToast("Failed to add to shelf. Please try again.", "error"); 162 163 }, 163 164 }); 164 165 ··· 171 172 path: { userDid: user?.did || "" }, 172 173 }), 173 174 }); 174 - Alert.alert("Success", "Removed from your shelf"); 175 + showToast("Removed from your shelf", "success"); 175 176 }, 176 177 onError: () => { 177 - Alert.alert("Error", "Failed to remove from shelf. Please try again."); 178 + showToast("Failed to remove from shelf. Please try again.", "error"); 178 179 }, 179 180 }); 180 181 181 182 const handleToggleWatched = useCallback( 182 183 (movieId: string, isWatched: boolean) => { 183 184 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 - ]); 185 + showToast("Sign in to track movies", "info"); 186 + router.push("/login"); 188 187 return; 189 188 } 190 189 ··· 194 193 markMutation.mutate({ body: { movieId } }); 195 194 } 196 195 }, 197 - [user, markMutation, unmarkMutation] 196 + [user, markMutation, unmarkMutation, router, showToast] 198 197 ); 199 198 200 199 const handleMoviePress = useCallback( ··· 238 237 const renderSkeleton = () => ( 239 238 <View style={styles.skeletonGrid}> 240 239 {[...Array(10)].map((_, i) => ( 241 - <View key={i} style={styles.movieItem}> 242 - <Skeleton width="100%" height={180} borderRadius={borderRadius.lg} /> 240 + <View key={i} style={styles.skeletonItem}> 241 + <Skeleton width="100%" height={210} borderRadius={borderRadius.lg} /> 243 242 <View style={{ marginTop: spacing.sm }}> 244 243 <Skeleton width="80%" height={16} /> 245 244 </View> ··· 279 278 keyExtractor={keyExtractor} 280 279 numColumns={2} 281 280 contentContainerStyle={styles.listContent} 281 + extraData={watchedMovieIds} 282 282 ListHeaderComponent={ 283 283 <Text style={styles.resultsCount}> 284 284 Found {data.total_results.toLocaleString()} results ··· 322 322 movieItem: { 323 323 flex: 1, 324 324 marginBottom: spacing.lg, 325 - maxWidth: "48%", 325 + marginHorizontal: spacing.sm, 326 + minWidth: 140, 327 + maxWidth: "47%", 326 328 }, 327 329 posterContainer: { 328 330 aspectRatio: 2 / 3, ··· 393 395 flexDirection: "row", 394 396 flexWrap: "wrap", 395 397 padding: spacing.lg, 396 - gap: spacing.md, 398 + paddingTop: 0, 399 + }, 400 + skeletonItem: { 401 + flex: 1, 402 + marginBottom: spacing.lg, 403 + marginHorizontal: spacing.sm, 404 + minWidth: 140, 405 + maxWidth: "47%", 406 + }, 407 + skeletonPoster: { 408 + aspectRatio: 2 / 3, 409 + borderRadius: borderRadius.lg, 410 + overflow: "hidden", 397 411 }, 398 412 });
+43 -12
apps/mobile/app/(tabs)/shelf.tsx
··· 7 7 import type { TrackedMovieDto } from "@opnshelf/api"; 8 8 import { FlashList } from "@shopify/flash-list"; 9 9 import { router } from "expo-router"; 10 - import { BookOpen, Loader2, LogIn, Trash2 } from "lucide-react-native"; 10 + import { BookOpen, Loader2, LogIn, LogOut, Trash2 } from "lucide-react-native"; 11 11 import { useCallback } from "react"; 12 12 import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; 13 13 import { SafeAreaView } from "react-native-safe-area-context"; 14 14 import { useAuth } from "@/contexts/auth"; 15 + import { useToast } from "@/contexts/toast"; 15 16 import { Badge } from "@/components/ui/Badge"; 16 17 import { Button } from "@/components/ui/Button"; 17 18 import { Card, CardContent, CardHeader } from "@/components/ui/Card"; ··· 96 97 }; 97 98 98 99 export default function ShelfScreen() { 99 - const { user, isLoading: isAuthLoading, isAuthenticated, login } = useAuth(); 100 + const { user, isLoading: isAuthLoading, isAuthenticated, logout } = useAuth(); 101 + const { showToast } = useToast(); 100 102 const queryClient = useQueryClient(); 101 103 102 104 // Fetch user's tracked movies ··· 116 118 path: { userDid: user?.did || "" }, 117 119 }), 118 120 }); 119 - Alert.alert("Success", "Removed from your shelf"); 121 + showToast("Removed from your shelf", "success"); 120 122 }, 121 123 onError: () => { 122 - Alert.alert("Error", "Failed to remove from shelf. Please try again."); 124 + showToast("Failed to remove from shelf. Please try again.", "error"); 123 125 }, 124 126 }); 125 127 ··· 171 173 </View> 172 174 <View style={styles.skeletonGrid}> 173 175 {[...Array(8)].map((_, i) => ( 174 - <View key={i} style={styles.movieItem}> 175 - <Skeleton width="100%" height={180} borderRadius={borderRadius.lg} /> 176 + <View key={i} style={styles.skeletonItem}> 177 + <View style={[styles.skeletonPoster, { backgroundColor: colors.cardMuted }]} /> 176 178 <View style={{ marginTop: spacing.sm }}> 177 179 <Skeleton width="80%" height={16} /> 178 180 </View> ··· 196 198 <BookOpen size={64} color={colors.primary} style={styles.authIcon} /> 197 199 <Text style={styles.authTitle}>My Shelf</Text> 198 200 <Text style={styles.authDescription}> 199 - Sign in to track movies you've watched 201 + Sign in to track movies you&apos;ve watched 200 202 </Text> 201 203 </CardHeader> 202 204 <CardContent> 203 - <Button size="lg" onPress={() => login()}> 205 + <Button size="lg" onPress={() => router.push("/login")}> 204 206 <LogIn size={20} color={colors.text} style={styles.buttonIcon} /> 205 207 <Text style={styles.buttonText}>Sign in</Text> 206 208 </Button> ··· 218 220 <BookOpen size={32} color={colors.primary} /> 219 221 <Text style={styles.title}>My Shelf</Text> 220 222 </View> 223 + <Pressable 224 + onPress={async () => { 225 + await logout(); 226 + await queryClient.resetQueries({ queryKey: ["authControllerMe"] }); 227 + showToast("Logged out successfully", "success"); 228 + }} 229 + style={styles.logoutButton} 230 + > 231 + <LogOut size={20} color={colors.textMuted} /> 232 + </Pressable> 221 233 </View> 222 234 223 235 {isMoviesLoading && ( 224 236 <View style={styles.skeletonGrid}> 225 237 {[...Array(8)].map((_, i) => ( 226 - <View key={i} style={styles.movieItem}> 227 - <Skeleton width="100%" height={180} borderRadius={borderRadius.lg} /> 238 + <View key={i} style={styles.skeletonItem}> 239 + <View style={[styles.skeletonPoster, { backgroundColor: colors.cardMuted }]} /> 228 240 <View style={{ marginTop: spacing.sm }}> 229 241 <Skeleton width="80%" height={16} /> 230 242 </View> ··· 282 294 header: { 283 295 paddingHorizontal: spacing.lg, 284 296 paddingVertical: spacing.md, 297 + flexDirection: "row", 298 + justifyContent: "space-between", 299 + alignItems: "center", 285 300 }, 286 301 headerContent: { 287 302 flexDirection: "row", 288 303 alignItems: "center", 289 304 gap: spacing.sm, 290 305 }, 306 + logoutButton: { 307 + padding: spacing.sm, 308 + }, 291 309 title: { 292 310 fontSize: 28, 293 311 fontWeight: "bold", ··· 299 317 movieItem: { 300 318 flex: 1, 301 319 marginBottom: spacing.lg, 302 - maxWidth: "48%", 320 + marginHorizontal: spacing.sm, 321 + minWidth: 140, 322 + maxWidth: "47%", 303 323 }, 304 324 posterContainer: { 305 325 aspectRatio: 2 / 3, ··· 419 439 flexDirection: "row", 420 440 flexWrap: "wrap", 421 441 padding: spacing.lg, 422 - gap: spacing.md, 442 + }, 443 + skeletonItem: { 444 + flex: 1, 445 + marginBottom: spacing.lg, 446 + marginHorizontal: spacing.sm, 447 + minWidth: 140, 448 + maxWidth: "47%", 449 + }, 450 + skeletonPoster: { 451 + aspectRatio: 2 / 3, 452 + borderRadius: borderRadius.lg, 453 + overflow: "hidden", 423 454 }, 424 455 });
+33 -30
apps/mobile/app/_layout.tsx
··· 3 3 import { StatusBar } from "expo-status-bar"; 4 4 import { useEffect } from "react"; 5 5 import { AuthProvider } from "@/contexts/auth"; 6 + import { ToastProvider } from "@/contexts/toast"; 6 7 import { initializeApiClient } from "@/lib/api"; 7 8 import { queryClient } from "@/lib/query-client"; 8 9 import { colors } from "@/constants/theme"; ··· 15 16 return ( 16 17 <QueryClientProvider client={queryClient}> 17 18 <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, 19 + <ToastProvider> 20 + <Stack 21 + screenOptions={{ 22 + headerStyle: { 23 + backgroundColor: colors.background, 24 + }, 38 25 headerTintColor: colors.text, 26 + headerTitleStyle: { 27 + color: colors.text, 28 + }, 29 + contentStyle: { 30 + backgroundColor: colors.background, 31 + }, 39 32 }} 40 - /> 41 - <Stack.Screen 42 - name="auth/callback" 43 - options={{ 44 - presentation: "modal", 45 - headerShown: false, 46 - }} 47 - /> 48 - </Stack> 49 - <StatusBar style="light" /> 33 + > 34 + <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> 35 + <Stack.Screen 36 + name="movie/[id]" 37 + options={{ 38 + title: "Movie Details", 39 + headerTransparent: true, 40 + headerTintColor: colors.text, 41 + }} 42 + /> 43 + <Stack.Screen 44 + name="auth/callback" 45 + options={{ 46 + presentation: "modal", 47 + headerShown: false, 48 + }} 49 + /> 50 + </Stack> 51 + <StatusBar style="light" /> 52 + </ToastProvider> 50 53 </AuthProvider> 51 54 </QueryClientProvider> 52 55 );
+51
apps/mobile/app/auth/complete.tsx
··· 1 + import { useEffect } from "react"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { Ionicons } from "@expo/vector-icons"; 4 + import { ActivityIndicator, Text, View } from "react-native"; 5 + import { useRouter, useLocalSearchParams } from "expo-router"; 6 + import { authControllerMeOptions } from "@opnshelf/api"; 7 + import { saveSessionToken } from "@/lib/api"; 8 + 9 + export default function AuthCompleteScreen() { 10 + const queryClient = useQueryClient(); 11 + const router = useRouter(); 12 + const params = useLocalSearchParams<{ session?: string }>(); 13 + const { session } = params; 14 + 15 + useEffect(() => { 16 + async function completeAuth() { 17 + if (session) { 18 + await saveSessionToken(session); 19 + } 20 + 21 + // Refetch auth query and wait for it to complete 22 + await queryClient.fetchQuery({ 23 + ...authControllerMeOptions(), 24 + staleTime: 0, 25 + }); 26 + 27 + // Small delay to ensure state is propagated 28 + await new Promise((resolve) => setTimeout(resolve, 100)); 29 + 30 + router.replace("/(tabs)/shelf"); 31 + } 32 + 33 + completeAuth(); 34 + }, [router, queryClient, session]); 35 + 36 + return ( 37 + <View 38 + style={{ 39 + flex: 1, 40 + backgroundColor: "#030712", 41 + justifyContent: "center", 42 + alignItems: "center", 43 + padding: 16, 44 + }} 45 + > 46 + <Ionicons name="film" size={48} color="#a855f7" /> 47 + <ActivityIndicator size="large" color="#a855f7" style={{ marginVertical: 16 }} /> 48 + <Text style={{ color: "#9ca3af" }}>Completing sign-in...</Text> 49 + </View> 50 + ); 51 + }
+271
apps/mobile/app/login.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { Ionicons } from "@expo/vector-icons"; 4 + import { 5 + ActivityIndicator, 6 + KeyboardAvoidingView, 7 + Platform, 8 + ScrollView, 9 + Text, 10 + TextInput, 11 + TouchableOpacity, 12 + View, 13 + } from "react-native"; 14 + import * as WebBrowser from "expo-web-browser"; 15 + import { useRouter, useLocalSearchParams } from "expo-router"; 16 + import { authControllerMeOptions, getLoginUrl } from "@opnshelf/api"; 17 + 18 + export default function LoginScreen() { 19 + const [handle, setHandle] = useState(""); 20 + const [isSubmitting, setIsSubmitting] = useState(false); 21 + const router = useRouter(); 22 + const params = useLocalSearchParams<{ 23 + error?: "auth_failed" | "callback_failed"; 24 + redirect?: string; 25 + reason?: "session_expired"; 26 + }>(); 27 + const { error, redirect, reason } = params; 28 + 29 + const { data: user, isLoading: isAuthLoading } = useQuery({ 30 + ...authControllerMeOptions(), 31 + staleTime: 5 * 60 * 1000, 32 + retry: false, 33 + }); 34 + 35 + useEffect(() => { 36 + if (user && !isAuthLoading) { 37 + if (redirect === "shelf") { 38 + router.replace("/(tabs)/shelf"); 39 + } else if (redirect === "search") { 40 + router.replace("/(tabs)/search"); 41 + } else { 42 + router.replace("/(tabs)/shelf"); 43 + } 44 + } 45 + }, [user, isAuthLoading, router, redirect]); 46 + 47 + const handleSubmit = async () => { 48 + setIsSubmitting(true); 49 + 50 + try { 51 + const loginUrl = `${getLoginUrl(handle || undefined)}&platform=mobile`; 52 + 53 + const result = await WebBrowser.openAuthSessionAsync( 54 + loginUrl, 55 + "opnshelf://auth/callback" 56 + ); 57 + 58 + if (result.type === "success") { 59 + const url = new URL(result.url); 60 + const session = url.searchParams.get("session"); 61 + if (session) { 62 + router.replace({ pathname: "/auth/complete", params: { session } }); 63 + } 64 + } else { 65 + setIsSubmitting(false); 66 + } 67 + } catch (err) { 68 + console.error("Auth error:", err); 69 + setIsSubmitting(false); 70 + } 71 + }; 72 + 73 + const errorMessages: Record<string, string> = { 74 + auth_failed: "Authentication failed. Please try again.", 75 + callback_failed: "Something went wrong during sign in. Please try again.", 76 + }; 77 + 78 + if (isAuthLoading) { 79 + return ( 80 + <View style={{ flex: 1, backgroundColor: "#030712", justifyContent: "center", alignItems: "center" }}> 81 + <ActivityIndicator size="large" color="#a855f7" /> 82 + </View> 83 + ); 84 + } 85 + 86 + return ( 87 + <KeyboardAvoidingView 88 + behavior={Platform.OS === "ios" ? "padding" : "height"} 89 + style={{ flex: 1, backgroundColor: "#030712" }} 90 + > 91 + <ScrollView 92 + style={{ flex: 1 }} 93 + contentContainerStyle={{ 94 + flexGrow: 1, 95 + paddingHorizontal: 16, 96 + paddingTop: 48, 97 + paddingBottom: 24, 98 + }} 99 + keyboardShouldPersistTaps="handled" 100 + > 101 + <View style={{ flex: 1, justifyContent: "center" }}> 102 + <View style={{ alignItems: "center", marginBottom: 32 }}> 103 + <View style={{ marginBottom: 16 }}> 104 + <Ionicons name="film" size={48} color="#8b5cf6" /> 105 + </View> 106 + <Text 107 + style={{ 108 + fontSize: 28, 109 + fontWeight: "bold", 110 + color: "#f9fafb", 111 + marginBottom: 8, 112 + }} 113 + > 114 + Sign in to OpnShelf 115 + </Text> 116 + <Text 117 + style={{ 118 + fontSize: 16, 119 + color: "#9ca3af", 120 + textAlign: "center", 121 + }} 122 + > 123 + Use your ATProto account to sign in 124 + </Text> 125 + </View> 126 + 127 + {reason === "session_expired" && ( 128 + <View 129 + style={{ 130 + marginBottom: 24, 131 + padding: 16, 132 + backgroundColor: "rgba(251, 191, 36, 0.1)", 133 + borderWidth: 1, 134 + borderColor: "rgba(251, 191, 36, 0.3)", 135 + borderRadius: 8, 136 + }} 137 + > 138 + <Text 139 + style={{ 140 + color: "#fcd34d", 141 + fontWeight: "600", 142 + marginBottom: 4, 143 + }} 144 + > 145 + You have been logged out 146 + </Text> 147 + <Text style={{ color: "rgba(252, 211, 77, 0.8)", fontSize: 14 }}> 148 + Your session has expired. Please sign in again to continue. 149 + </Text> 150 + </View> 151 + )} 152 + 153 + {error && ( 154 + <View 155 + style={{ 156 + marginBottom: 24, 157 + padding: 16, 158 + backgroundColor: "rgba(239, 68, 68, 0.1)", 159 + borderWidth: 1, 160 + borderColor: "rgba(239, 68, 68, 0.3)", 161 + borderRadius: 8, 162 + flexDirection: "row", 163 + alignItems: "flex-start", 164 + gap: 12, 165 + }} 166 + > 167 + <Ionicons name="alert-circle" size={20} color="#f87171" /> 168 + <Text 169 + style={{ 170 + color: "#fecaca", 171 + fontSize: 14, 172 + flex: 1, 173 + }} 174 + > 175 + {errorMessages[error] || "An error occurred. Please try again."} 176 + </Text> 177 + </View> 178 + )} 179 + 180 + <View style={{ gap: 24 }}> 181 + <View> 182 + <Text 183 + style={{ 184 + fontSize: 14, 185 + fontWeight: "500", 186 + color: "#d1d5db", 187 + marginBottom: 8, 188 + }} 189 + > 190 + Handle 191 + </Text> 192 + <TextInput 193 + style={{ 194 + width: "100%", 195 + paddingHorizontal: 16, 196 + paddingVertical: 12, 197 + backgroundColor: "#111827", 198 + borderWidth: 1, 199 + borderColor: "#374151", 200 + borderRadius: 8, 201 + color: "#ffffff", 202 + fontSize: 16, 203 + }} 204 + value={handle} 205 + onChangeText={setHandle} 206 + placeholder="username.bsky.social" 207 + placeholderTextColor="#6b7280" 208 + autoCapitalize="none" 209 + autoCorrect={false} 210 + keyboardType="email-address" 211 + editable={!isSubmitting} 212 + /> 213 + </View> 214 + 215 + <TouchableOpacity 216 + style={{ 217 + flexDirection: "row", 218 + alignItems: "center", 219 + justifyContent: "center", 220 + gap: 8, 221 + paddingHorizontal: 16, 222 + paddingVertical: 12, 223 + backgroundColor: isSubmitting ? "#5b21b6" : "#7c3aed", 224 + borderRadius: 8, 225 + opacity: isSubmitting ? 0.7 : 1, 226 + }} 227 + onPress={handleSubmit} 228 + disabled={isSubmitting} 229 + activeOpacity={0.8} 230 + > 231 + {isSubmitting ? ( 232 + <> 233 + <ActivityIndicator size="small" color="#fff" /> 234 + <Text style={{ color: "#ffffff", fontWeight: "600", fontSize: 16 }}> 235 + Redirecting... 236 + </Text> 237 + </> 238 + ) : ( 239 + <> 240 + <Ionicons name="log-in" size={20} color="#fff" /> 241 + <Text style={{ color: "#ffffff", fontWeight: "600", fontSize: 16 }}> 242 + Sign in 243 + </Text> 244 + </> 245 + )} 246 + </TouchableOpacity> 247 + 248 + <Text 249 + style={{ 250 + textAlign: "center", 251 + fontSize: 14, 252 + color: "#9ca3af", 253 + }} 254 + > 255 + Don&apos;t have an account?{" "} 256 + <Text 257 + style={{ 258 + color: "#8b5cf6", 259 + textDecorationLine: "underline", 260 + }} 261 + onPress={() => {}} 262 + > 263 + Sign up on Bluesky 264 + </Text> 265 + </Text> 266 + </View> 267 + </View> 268 + </ScrollView> 269 + </KeyboardAvoidingView> 270 + ); 271 + }
+409 -342
apps/mobile/app/movie/[id].tsx
··· 1 1 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 + import { useMemo, useState, useCallback } from "react"; 2 3 import { 3 4 moviesControllerDeleteWatchHistoryEntryMutation, 4 5 moviesControllerGetMovieDetailsOptions, ··· 11 12 import type { TmdbMovieDetailDto, WatchHistoryItemDto } from "@opnshelf/api"; 12 13 import DateTimePicker, { DateTimePickerEvent } from "@react-native-community/datetimepicker"; 13 14 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"; 15 + import { Image } from "expo-image"; 16 + import { Ionicons } from "@expo/vector-icons"; 28 17 import { 29 - Alert, 18 + ActivityIndicator, 30 19 Modal, 31 20 Pressable, 32 21 ScrollView, 33 22 StyleSheet, 34 23 Text, 24 + TouchableOpacity, 35 25 View, 36 26 } from "react-native"; 37 27 import { SafeAreaView } from "react-native-safe-area-context"; 38 28 import { useAuth } from "@/contexts/auth"; 29 + import { useToast } from "@/contexts/toast"; 39 30 import { Badge } from "@/components/ui/Badge"; 40 31 import { Button } from "@/components/ui/Button"; 41 - import { Card } from "@/components/ui/Card"; 42 - import { Skeleton } from "@/components/ui/Skeleton"; 43 32 import { colors, spacing, borderRadius } from "@/constants/theme"; 44 - import { Image } from "expo-image"; 45 - import { format } from "date-fns"; 46 33 47 34 const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w500"; 48 35 const BACKDROP_BASE_URL = "https://image.tmdb.org/t/p/w1280"; ··· 53 40 const mins = minutes % 60; 54 41 if (mins === 0) return `${hours}h`; 55 42 return `${hours}h ${mins}m`; 43 + } 44 + 45 + function formatWatchDate(dateString: string): string { 46 + return new Date(dateString).toLocaleString("en-US", { 47 + year: "numeric", 48 + month: "short", 49 + day: "numeric", 50 + hour: "2-digit", 51 + minute: "2-digit", 52 + hour12: false, 53 + }); 56 54 } 57 55 58 56 export default function MovieDetailScreen() { 59 57 const { id: movieId, title } = useLocalSearchParams<{ id: string; title?: string }>(); 60 58 const router = useRouter(); 61 59 const { user } = useAuth(); 60 + const { showToast } = useToast(); 62 61 const queryClient = useQueryClient(); 63 62 64 63 const [showHours, setShowHours] = useState(false); ··· 76 75 }); 77 76 78 77 const movie = movieData as TmdbMovieDetailDto | undefined; 78 + 79 + // Use server-provided colors with fallbacks 80 + const movieColors = movie?.colors || { 81 + primary: "#8b5cf6", 82 + secondary: "#6366f1", 83 + accent: "#a855f7", 84 + muted: "#4c1d95", 85 + }; 79 86 80 87 // Fetch user's tracked movies 81 88 const { data: trackedMovies } = useQuery({ ··· 113 120 // Format the watched date 114 121 const formattedWatchedDate = useMemo(() => { 115 122 if (!trackedMovie?.watchedDate) return null; 116 - return format(new Date(trackedMovie.watchedDate), "MMM d, yyyy HH:mm"); 123 + return formatWatchDate(trackedMovie.watchedDate); 117 124 }, [trackedMovie]); 118 125 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 126 // Mutations 128 127 const markMutation = useMutation({ 129 128 ...moviesControllerMarkWatchedMutation(), ··· 137 136 queryKey: ["watchHistory", user?.did, movieId], 138 137 }); 139 138 setShowDateModal(false); 140 - Alert.alert("Success", "Added to your shelf"); 139 + showToast("Added to your shelf", "success"); 141 140 }, 142 141 onError: () => { 143 - Alert.alert("Error", "Failed to add. Please try again."); 142 + showToast("Failed to add. Please try again.", "error"); 144 143 }, 145 144 }); 146 145 ··· 155 154 queryClient.invalidateQueries({ 156 155 queryKey: ["watchHistory", user?.did, movieId], 157 156 }); 158 - Alert.alert("Success", "Removed from your shelf"); 157 + showToast("Removed from your shelf", "success"); 159 158 }, 160 159 onError: () => { 161 - Alert.alert("Error", "Failed to remove. Please try again."); 160 + showToast("Failed to remove. Please try again.", "error"); 162 161 }, 163 162 }); 164 163 ··· 173 172 queryClient.invalidateQueries({ 174 173 queryKey: ["watchHistory", user?.did, movieId], 175 174 }); 176 - Alert.alert("Success", "Watch entry removed"); 175 + showToast("Watch entry removed", "success"); 177 176 }, 178 177 onError: () => { 179 - Alert.alert("Error", "Failed to remove watch entry. Please try again."); 178 + showToast("Failed to remove watch entry. Please try again.", "error"); 180 179 }, 181 180 }); 182 181 ··· 251 250 return ( 252 251 <SafeAreaView style={styles.container}> 253 252 <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> 253 + <ActivityIndicator size="large" color={movieColors.primary} /> 258 254 </View> 259 255 </SafeAreaView> 260 256 ); 261 257 } 262 258 263 259 return ( 264 - <View style={styles.container}> 265 - <ScrollView> 260 + <> 261 + <ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}> 266 262 {/* Hero Section with Backdrop */} 267 - <View style={styles.heroContainer}> 263 + <View style={styles.heroWrapper}> 268 264 {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 - ]} 265 + <Image 266 + source={{ uri: backdropUrl }} 267 + style={styles.backdrop} 268 + contentFit="cover" 284 269 /> 270 + ) : ( 271 + <View style={[styles.backdrop, { backgroundColor: movieColors.muted }]} /> 285 272 )} 286 273 287 274 {/* Back button */} 288 - <Pressable onPress={() => router.back()} style={styles.backButton}> 289 - <ArrowLeft size={24} color={colors.text} /> 290 - </Pressable> 275 + <TouchableOpacity 276 + onPress={() => router.back()} 277 + style={styles.backButton} 278 + activeOpacity={0.8} 279 + > 280 + <Ionicons name="arrow-back" size={24} color="#f9fafb" /> 281 + </TouchableOpacity> 291 282 292 - {/* Hero Content */} 293 - <View style={styles.heroContent}> 294 - {posterUrl && ( 295 - <View 296 - style={[ 297 - styles.posterContainer, 298 - { shadowColor: movieColors.primary }, 299 - ]} 300 - > 283 + {/* Poster and Title Overlay */} 284 + <View style={styles.heroOverlay}> 285 + <View style={styles.posterWrapper}> 286 + {posterUrl ? ( 301 287 <Image 302 288 source={{ uri: posterUrl }} 303 289 style={styles.poster} 304 290 contentFit="cover" 305 291 /> 306 - </View> 307 - )} 292 + ) : ( 293 + <View style={[styles.poster, styles.noPoster]}> 294 + <Text style={styles.noPosterText}>No poster</Text> 295 + </View> 296 + )} 297 + </View> 308 298 309 - <View style={styles.titleContainer}> 310 - <Text style={[styles.title, { textShadowColor: `${movieColors.primary}60` }]}> 311 - {movie?.title} 299 + <View style={styles.titleWrapper}> 300 + <Text 301 + style={[styles.title, { textShadowColor: movieColors.primary }]} 302 + numberOfLines={2} 303 + adjustsFontSizeToFit 304 + minimumFontScale={0.7} 305 + > 306 + {movie?.title || title} 312 307 </Text> 313 - {releaseYear && ( 314 - <View style={styles.metaRow}> 308 + <View style={styles.metaRow}> 309 + {!!releaseYear && ( 315 310 <View style={styles.metaItem}> 316 - <Calendar size={16} color={movieColors.accent} /> 311 + <Ionicons name="calendar-outline" size={14} color={movieColors.accent} /> 317 312 <Text style={styles.metaText}>{releaseYear}</Text> 318 313 </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 - )} 314 + )} 315 + {movie?.runtime && ( 316 + <TouchableOpacity 317 + onPress={() => setShowHours(!showHours)} 318 + style={styles.metaItem} 319 + activeOpacity={0.8} 320 + > 321 + <Ionicons name="time-outline" size={14} color={movieColors.accent} /> 322 + <Text style={styles.metaText}> 323 + {formatRuntime(movie.runtime, showHours)} 324 + </Text> 325 + </TouchableOpacity> 326 + )} 327 + </View> 329 328 </View> 330 329 </View> 331 330 </View> ··· 337 336 {user ? ( 338 337 !isWatched ? ( 339 338 <> 340 - <Button 341 - size="lg" 339 + <TouchableOpacity 342 340 onPress={handleMarkWatched} 343 - isLoading={isPending} 341 + disabled={isPending} 344 342 style={[ 345 343 styles.primaryButton, 346 - { 347 - backgroundColor: movieColors.primary, 348 - shadowColor: movieColors.primary, 349 - }, 344 + { backgroundColor: movieColors.primary, opacity: isPending ? 0.7 : 1 }, 350 345 ]} 346 + activeOpacity={0.8} 351 347 > 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> 348 + {isPending ? ( 349 + <ActivityIndicator color="#f9fafb" /> 350 + ) : ( 351 + <View style={styles.buttonContent}> 352 + <Ionicons name="add" size={20} color="#f9fafb" /> 353 + <Text style={styles.buttonText}>Add to Shelf</Text> 354 + </View> 355 + )} 356 + </TouchableOpacity> 357 + 358 + <TouchableOpacity 359 + onPress={openDateModal} 360 + style={styles.secondaryButton} 361 + activeOpacity={0.8} 362 + > 363 + <View style={styles.buttonContent}> 364 + <Ionicons name="calendar" size={18} color="#9ca3af" /> 365 + <Text style={styles.secondaryButtonText}> 366 + Add on Different Date 367 + </Text> 368 + </View> 369 + </TouchableOpacity> 359 370 </> 360 371 ) : ( 361 372 <> 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" 373 + <TouchableOpacity 408 374 onPress={handleMarkWatched} 409 - isLoading={isPending} 375 + disabled={isPending} 410 376 style={[ 411 377 styles.primaryButton, 412 - { 413 - backgroundColor: movieColors.primary, 414 - shadowColor: movieColors.primary, 415 - }, 378 + { backgroundColor: movieColors.primary, opacity: isPending ? 0.7 : 1 }, 416 379 ]} 380 + activeOpacity={0.8} 417 381 > 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> 382 + {isPending ? ( 383 + <ActivityIndicator color="#f9fafb" /> 384 + ) : ( 385 + <View style={styles.buttonContent}> 386 + <Ionicons name="refresh" size={20} color="#f9fafb" /> 387 + <Text style={styles.buttonText}>Watch Now</Text> 388 + </View> 389 + )} 390 + </TouchableOpacity> 391 + 392 + <TouchableOpacity 393 + onPress={openDateModal} 394 + style={styles.secondaryButton} 395 + activeOpacity={0.8} 396 + > 397 + <View style={styles.buttonContent}> 398 + <Ionicons name="calendar" size={18} color="#9ca3af" /> 399 + <Text style={styles.secondaryButtonText}> 400 + Watch on Different Date 401 + </Text> 402 + </View> 403 + </TouchableOpacity> 425 404 </> 426 405 ) 427 406 ) : ( 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 - ]} 407 + <TouchableOpacity 408 + onPress={() => router.push("/login")} 409 + style={[styles.primaryButton, { backgroundColor: movieColors.primary }]} 410 + activeOpacity={0.8} 438 411 > 439 412 <Text style={styles.buttonText}>Sign in to Track</Text> 440 - </Button> 413 + </TouchableOpacity> 441 414 )} 442 415 </View> 443 416 417 + {/* Watched Info Card */} 418 + {isWatched && ( 419 + <View style={styles.watchedCard}> 420 + <View style={styles.watchedHeader}> 421 + <Ionicons name="checkmark-circle" size={20} color="#22c55e" /> 422 + <Text style={styles.watchedText}>On Your Shelf</Text> 423 + </View> 424 + {formattedWatchedDate && ( 425 + <View style={styles.watchedDateRow}> 426 + <Text style={styles.watchedDateText}> 427 + Watched on {formattedWatchedDate} 428 + </Text> 429 + {watchHistory && watchHistory.length > 1 && ( 430 + <Badge variant="secondary">{watchHistory.length} watches</Badge> 431 + )} 432 + </View> 433 + )} 434 + {watchHistory && watchHistory.length > 1 && ( 435 + <TouchableOpacity 436 + onPress={() => setShowHistoryModal(true)} 437 + style={styles.viewHistoryRow} 438 + activeOpacity={0.7} 439 + > 440 + <Ionicons name="eye" size={16} color="#9ca3af" /> 441 + <Text style={styles.viewHistoryText}>View all watches</Text> 442 + </TouchableOpacity> 443 + )} 444 + {watchHistory && watchHistory.length === 1 && ( 445 + <TouchableOpacity 446 + onPress={handleUnmarkWatched} 447 + disabled={unmarkMutation.isPending} 448 + style={styles.removeRow} 449 + activeOpacity={0.7} 450 + > 451 + {unmarkMutation.isPending ? ( 452 + <ActivityIndicator size="small" color="#ef4444" /> 453 + ) : ( 454 + <> 455 + <Ionicons name="trash-outline" size={16} color="#ef4444" /> 456 + <Text style={styles.removeText}>Remove from shelf</Text> 457 + </> 458 + )} 459 + </TouchableOpacity> 460 + )} 461 + </View> 462 + )} 463 + 444 464 {/* 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> 465 + {movie?.overview && ( 466 + <View style={styles.section}> 467 + <Text style={[styles.sectionTitle, { color: movieColors.primary }]}> 468 + Overview 469 + </Text> 470 + <Text style={styles.overview}>{movie.overview}</Text> 471 + </View> 472 + )} 451 473 452 474 {/* Additional Info */} 453 475 <View style={styles.infoGrid}> 454 476 {movie?.release_date && ( 455 - <Card style={styles.infoCard}> 477 + <View style={styles.infoCard}> 456 478 <Text style={styles.infoLabel}>Release Date</Text> 457 479 <Text style={[styles.infoValue, { color: movieColors.accent }]}> 458 - {format(new Date(movie.release_date), "MMMM d, yyyy")} 480 + {new Date(movie.release_date).toLocaleDateString("en-US", { 481 + year: "numeric", 482 + month: "short", 483 + day: "numeric", 484 + })} 459 485 </Text> 460 - </Card> 486 + </View> 461 487 )} 462 488 {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> 489 + <TouchableOpacity 490 + onPress={() => setShowHours(!showHours)} 491 + style={styles.infoCard} 492 + activeOpacity={0.8} 493 + > 494 + <Text style={styles.infoLabel}>Runtime</Text> 495 + <Text style={[styles.infoValue, { color: movieColors.accent }]}> 496 + {formatRuntime(movie.runtime, showHours)} 497 + </Text> 498 + </TouchableOpacity> 471 499 )} 472 - {movie?.vote_average && ( 473 - <Card style={styles.infoCard}> 500 + {movie?.vote_average !== undefined && ( 501 + <View style={styles.infoCard}> 474 502 <Text style={styles.infoLabel}>Rating</Text> 475 503 <Text style={[styles.infoValue, { color: movieColors.accent }]}> 476 504 {movie.vote_average.toFixed(1)}/10 477 505 </Text> 478 - </Card> 506 + </View> 479 507 )} 480 - {movie?.vote_count && ( 481 - <Card style={styles.infoCard}> 508 + {movie?.vote_count !== undefined && ( 509 + <View style={styles.infoCard}> 482 510 <Text style={styles.infoLabel}>Votes</Text> 483 511 <Text style={[styles.infoValue, { color: movieColors.accent }]}> 484 512 {movie.vote_count.toLocaleString()} 485 513 </Text> 486 - </Card> 514 + </View> 487 515 )} 488 516 </View> 489 517 ··· 495 523 </Text> 496 524 <View style={styles.genresContainer}> 497 525 {movie.genres.map((genre) => ( 498 - <Badge 526 + <View 499 527 key={genre.id} 500 - variant="outline" 501 528 style={[ 502 529 styles.genreBadge, 503 530 { ··· 509 536 <Text style={[styles.genreText, { color: movieColors.accent }]}> 510 537 {genre.name} 511 538 </Text> 512 - </Badge> 539 + </View> 513 540 ))} 514 541 </View> 515 542 </View> ··· 520 547 {/* Date Picker Modal */} 521 548 <Modal 522 549 visible={showDateModal} 523 - animationType="slide" 550 + animationType="fade" 524 551 transparent={true} 525 552 onRequestClose={() => setShowDateModal(false)} 526 553 > ··· 529 556 <View style={styles.modalHeader}> 530 557 <Text style={styles.modalTitle}>Watch Again</Text> 531 558 <Pressable onPress={() => setShowDateModal(false)}> 532 - <X size={24} color={colors.text} /> 559 + <Ionicons name="close" size={24} color={colors.text} /> 533 560 </Pressable> 534 561 </View> 535 562 <Text style={styles.modalDescription}>When did you watch this movie?</Text> 536 563 537 564 <View style={styles.dateTimeContainer}> 538 - <Pressable onPress={() => setShowDatePicker(true)} style={styles.dateTimeButton}> 539 - <Calendar size={20} color={colors.textMuted} /> 565 + <TouchableOpacity 566 + onPress={() => setShowDatePicker(true)} 567 + style={styles.dateTimeButton} 568 + activeOpacity={0.7} 569 + > 570 + <Ionicons name="calendar-outline" size={20} color={colors.textMuted} /> 540 571 <Text style={styles.dateTimeText}> 541 - {format(customDate, "MMMM d, yyyy")} 572 + {customDate.toLocaleDateString("en-US", { 573 + year: "numeric", 574 + month: "short", 575 + day: "numeric", 576 + })} 542 577 </Text> 543 - </Pressable> 544 - <Pressable onPress={() => setShowTimePicker(true)} style={styles.dateTimeButton}> 545 - <Clock size={20} color={colors.textMuted} /> 578 + </TouchableOpacity> 579 + <TouchableOpacity 580 + onPress={() => setShowTimePicker(true)} 581 + style={styles.dateTimeButton} 582 + activeOpacity={0.7} 583 + > 584 + <Ionicons name="time-outline" size={20} color={colors.textMuted} /> 546 585 <Text style={styles.dateTimeText}> 547 - {format(customDate, "HH:mm")} 586 + {customDate.toLocaleTimeString("en-US", { 587 + hour: "2-digit", 588 + minute: "2-digit", 589 + hour12: false, 590 + })} 548 591 </Text> 549 - </Pressable> 592 + </TouchableOpacity> 550 593 </View> 551 594 552 - <View style={styles.modalActions}> 595 + {/* Date/Time Pickers inside modal */} 596 + {showDatePicker && ( 597 + <DateTimePicker value={customDate} mode="date" onChange={onDateChange} /> 598 + )} 599 + {showTimePicker && ( 600 + <DateTimePicker 601 + value={customDate} 602 + mode="time" 603 + is24Hour={true} 604 + onChange={onTimeChange} 605 + /> 606 + )} 607 + 608 + <View style={styles.modalActionsSplit}> 553 609 <Button variant="outline" onPress={() => setShowDateModal(false)}> 554 610 <Text style={styles.secondaryButtonText}>Cancel</Text> 555 611 </Button> ··· 568 624 {/* History Modal */} 569 625 <Modal 570 626 visible={showHistoryModal} 571 - animationType="slide" 627 + animationType="fade" 572 628 transparent={true} 573 629 onRequestClose={() => setShowHistoryModal(false)} 574 630 > ··· 576 632 <View style={styles.modalContent}> 577 633 <View style={styles.modalHeader}> 578 634 <View style={styles.modalTitleContainer}> 579 - <History size={20} color={colors.text} /> 635 + <Ionicons name="time" size={20} color={colors.primary} /> 580 636 <Text style={styles.modalTitle}>Watch History</Text> 581 637 </View> 582 638 <Pressable onPress={() => setShowHistoryModal(false)}> 583 - <X size={24} color={colors.text} /> 639 + <Ionicons name="close" size={24} color={colors.text} /> 584 640 </Pressable> 585 641 </View> 586 642 <Text style={styles.modalDescription}> 587 - All the times you've watched {movie?.title} 643 + All the times you&apos;ve watched {movie?.title} 588 644 </Text> 589 645 590 646 <ScrollView style={styles.historyList}> 591 647 {watchHistory && watchHistory.length > 0 ? ( 592 648 watchHistory.map((watch) => ( 593 649 <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 650 + <Text style={styles.historyDate}>{formatWatchDate(watch.watchedDate)}</Text> 651 + <TouchableOpacity 598 652 onPress={() => handleDeleteWatchEntry(watch.id)} 599 653 disabled={deleteWatchEntryMutation.isPending} 600 654 style={styles.historyDeleteButton} 655 + activeOpacity={0.7} 601 656 > 602 657 {deleteWatchEntryMutation.isPending && 603 - deleteWatchEntryMutation.variables?.path?.trackedMovieId === watch.id ? ( 604 - <Loader2 size={16} color={colors.textMuted} /> 658 + deleteWatchEntryMutation.variables?.path?.trackedMovieId === 659 + watch.id ? ( 660 + <ActivityIndicator size="small" color={colors.textMuted} /> 605 661 ) : ( 606 - <Trash2 size={16} color={colors.error} /> 662 + <Ionicons name="trash-outline" size={18} color="#ef4444" /> 607 663 )} 608 - </Pressable> 664 + </TouchableOpacity> 609 665 </View> 610 666 )) 611 667 ) : ( ··· 620 676 </View> 621 677 </Modal> 622 678 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> 679 + </> 640 680 ); 641 681 } 642 682 ··· 645 685 flex: 1, 646 686 backgroundColor: colors.background, 647 687 }, 688 + scrollContent: { 689 + paddingBottom: 32, 690 + }, 648 691 loadingContainer: { 649 692 flex: 1, 650 693 justifyContent: "center", 651 694 alignItems: "center", 652 695 }, 653 - heroContainer: { 654 - height: 400, 696 + heroWrapper: { 697 + height: 256, 655 698 position: "relative", 656 699 }, 657 700 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)", 701 + width: "100%", 702 + height: "100%", 668 703 }, 669 704 backButton: { 670 705 position: "absolute", 671 - top: spacing.lg, 672 - left: spacing.lg, 706 + top: 48, 707 + left: 16, 673 708 zIndex: 10, 674 - padding: spacing.sm, 709 + padding: 8, 675 710 borderRadius: borderRadius.full, 676 711 backgroundColor: "rgba(0, 0, 0, 0.5)", 677 712 }, 678 - heroContent: { 713 + heroOverlay: { 679 714 position: "absolute", 680 - bottom: 0, 681 - left: 0, 682 - right: 0, 683 - padding: spacing.lg, 715 + bottom: -64, 716 + left: 16, 717 + right: 16, 684 718 flexDirection: "row", 685 719 alignItems: "flex-end", 686 720 }, 687 - posterContainer: { 688 - width: 120, 721 + posterWrapper: { 689 722 borderRadius: borderRadius.lg, 690 723 overflow: "hidden", 691 - shadowOffset: { width: 0, height: 10 }, 692 - shadowOpacity: 0.5, 693 - shadowRadius: 20, 694 - elevation: 10, 724 + shadowColor: colors.primary, 725 + shadowOffset: { width: 0, height: 4 }, 726 + shadowOpacity: 0.4, 727 + shadowRadius: 8, 728 + elevation: 8, 695 729 }, 696 730 poster: { 697 - width: "100%", 698 - aspectRatio: 2 / 3, 731 + width: 112, 732 + height: 160, 733 + }, 734 + noPoster: { 735 + backgroundColor: "#111827", 736 + justifyContent: "center", 737 + alignItems: "center", 738 + }, 739 + noPosterText: { 740 + color: "#4b5563", 741 + fontSize: 12, 699 742 }, 700 - titleContainer: { 743 + titleWrapper: { 744 + marginLeft: 16, 745 + marginBottom: 16, 701 746 flex: 1, 702 - marginLeft: spacing.md, 703 - paddingBottom: spacing.sm, 704 747 }, 705 748 title: { 706 749 fontSize: 24, 707 750 fontWeight: "bold", 708 - color: colors.text, 709 - textShadowOffset: { width: 0, height: 4 }, 710 - textShadowRadius: 30, 711 - marginBottom: spacing.sm, 751 + color: "#f9fafb", 752 + textShadowOffset: { width: 0, height: 2 }, 753 + textShadowRadius: 8, 712 754 }, 713 755 metaRow: { 714 756 flexDirection: "row", 715 - gap: spacing.md, 757 + alignItems: "center", 758 + marginTop: 8, 759 + gap: 12, 716 760 }, 717 761 metaItem: { 718 762 flexDirection: "row", 719 763 alignItems: "center", 720 - gap: spacing.xs, 721 764 }, 722 765 metaText: { 723 766 fontSize: 14, 724 - color: colors.textMuted, 767 + color: "#9ca3af", 768 + marginLeft: 4, 725 769 }, 726 770 content: { 727 - padding: spacing.lg, 771 + marginTop: 80, 772 + paddingHorizontal: 16, 728 773 }, 729 774 actionsContainer: { 730 - gap: spacing.md, 731 - marginBottom: spacing.xl, 775 + gap: 12, 776 + marginBottom: 24, 732 777 }, 733 778 primaryButton: { 734 - shadowOffset: { width: 0, height: 10 }, 735 - shadowOpacity: 0.3, 736 - shadowRadius: 15, 737 - elevation: 5, 779 + borderRadius: 12, 780 + paddingVertical: 16, 781 + paddingHorizontal: 24, 782 + alignItems: "center", 783 + justifyContent: "center", 738 784 }, 739 - buttonIcon: { 740 - marginRight: spacing.sm, 785 + secondaryButton: { 786 + borderRadius: 12, 787 + paddingVertical: 12, 788 + paddingHorizontal: 24, 789 + alignItems: "center", 790 + justifyContent: "center", 791 + borderWidth: 1, 792 + borderColor: "#374151", 793 + }, 794 + buttonContent: { 795 + flexDirection: "row", 796 + alignItems: "center", 797 + gap: 8, 741 798 }, 742 799 buttonText: { 743 - color: colors.text, 744 - fontSize: 16, 800 + color: "#f9fafb", 801 + fontSize: 18, 745 802 fontWeight: "600", 746 803 }, 747 804 secondaryButtonText: { 748 - color: colors.textMuted, 805 + color: "#9ca3af", 749 806 fontSize: 16, 750 - fontWeight: "600", 807 + fontWeight: "500", 751 808 }, 752 809 watchedCard: { 753 - padding: spacing.md, 810 + backgroundColor: "rgba(17, 24, 39, 0.5)", 811 + borderRadius: 12, 812 + borderWidth: 1, 813 + borderColor: "#1f2937", 814 + padding: 16, 815 + marginBottom: 24, 754 816 }, 755 817 watchedHeader: { 756 818 flexDirection: "row", 757 819 alignItems: "center", 758 - gap: spacing.xs, 759 - marginBottom: spacing.xs, 820 + marginBottom: 8, 760 821 }, 761 822 watchedText: { 762 - color: colors.success, 823 + color: "#22c55e", 763 824 fontSize: 16, 764 825 fontWeight: "600", 826 + marginLeft: 8, 765 827 }, 766 - watchedDate: { 767 - fontSize: 14, 768 - color: colors.textMuted, 769 - marginBottom: spacing.xs, 770 - }, 771 - watchCount: { 828 + watchedDateRow: { 772 829 flexDirection: "row", 773 830 alignItems: "center", 774 - gap: spacing.xs, 775 - marginTop: spacing.xs, 831 + flexWrap: "wrap", 832 + gap: 8, 776 833 }, 777 - watchCountText: { 778 - fontSize: 12, 779 - color: colors.textMuted, 834 + watchedDateText: { 835 + fontSize: 14, 836 + color: "#9ca3af", 780 837 }, 781 - viewHistoryButton: { 838 + viewHistoryRow: { 782 839 flexDirection: "row", 783 840 alignItems: "center", 784 - gap: spacing.xs, 785 - marginTop: spacing.sm, 841 + marginTop: 12, 786 842 }, 787 843 viewHistoryText: { 788 844 fontSize: 14, 789 - color: colors.textMuted, 845 + color: "#9ca3af", 846 + marginLeft: 8, 790 847 }, 791 - removeButton: { 848 + removeRow: { 792 849 flexDirection: "row", 793 850 alignItems: "center", 794 - gap: spacing.xs, 795 - marginTop: spacing.md, 851 + marginTop: 12, 796 852 }, 797 - removeButtonText: { 853 + removeText: { 798 854 fontSize: 14, 799 - color: colors.error, 855 + color: "#ef4444", 856 + marginLeft: 8, 800 857 }, 801 858 section: { 802 - marginBottom: spacing.xl, 859 + marginBottom: 24, 803 860 }, 804 861 sectionTitle: { 805 862 fontSize: 20, 806 863 fontWeight: "600", 807 - marginBottom: spacing.md, 864 + marginBottom: 12, 808 865 }, 809 866 overview: { 810 867 fontSize: 16, 811 - color: colors.textMuted, 868 + color: "#d1d5db", 812 869 lineHeight: 24, 813 870 }, 814 871 infoGrid: { 815 872 flexDirection: "row", 816 873 flexWrap: "wrap", 817 - gap: spacing.md, 818 - marginBottom: spacing.xl, 874 + gap: 12, 875 + marginBottom: 24, 819 876 }, 820 877 infoCard: { 878 + backgroundColor: "#111827", 879 + borderRadius: 8, 880 + padding: 12, 821 881 flex: 1, 822 882 minWidth: "45%", 823 - padding: spacing.md, 824 883 }, 825 884 infoLabel: { 826 885 fontSize: 12, 827 - color: colors.textMuted, 828 - marginBottom: spacing.xs, 886 + color: "#6b7280", 887 + marginBottom: 4, 829 888 }, 830 889 infoValue: { 831 890 fontSize: 16, ··· 834 893 genresContainer: { 835 894 flexDirection: "row", 836 895 flexWrap: "wrap", 837 - gap: spacing.sm, 896 + gap: 8, 838 897 }, 839 898 genreBadge: { 840 - paddingHorizontal: spacing.md, 841 - paddingVertical: spacing.sm, 899 + paddingHorizontal: 12, 900 + paddingVertical: 6, 901 + borderRadius: borderRadius.full, 902 + borderWidth: 1, 842 903 }, 843 904 genreText: { 844 905 fontSize: 14, ··· 848 909 flex: 1, 849 910 backgroundColor: "rgba(0, 0, 0, 0.7)", 850 911 justifyContent: "center", 851 - padding: spacing.lg, 912 + padding: 16, 852 913 }, 853 914 modalContent: { 854 915 backgroundColor: colors.card, 855 - borderRadius: borderRadius.xl, 856 - padding: spacing.lg, 857 - gap: spacing.md, 916 + borderRadius: 16, 917 + padding: 16, 918 + gap: 12, 858 919 }, 859 920 modalHeader: { 860 921 flexDirection: "row", ··· 864 925 modalTitleContainer: { 865 926 flexDirection: "row", 866 927 alignItems: "center", 867 - gap: spacing.sm, 928 + gap: 8, 868 929 }, 869 930 modalTitle: { 870 931 fontSize: 20, ··· 873 934 }, 874 935 modalDescription: { 875 936 fontSize: 14, 876 - color: colors.textMuted, 937 + color: "#9ca3af", 877 938 }, 878 939 dateTimeContainer: { 879 - gap: spacing.md, 940 + gap: 12, 880 941 }, 881 942 dateTimeButton: { 882 943 flexDirection: "row", 883 944 alignItems: "center", 884 - gap: spacing.md, 885 - padding: spacing.md, 886 - backgroundColor: colors.cardMuted, 887 - borderRadius: borderRadius.lg, 945 + gap: 12, 946 + padding: 16, 947 + backgroundColor: "#111827", 948 + borderRadius: 12, 888 949 }, 889 950 dateTimeText: { 890 951 fontSize: 16, 891 - color: colors.text, 952 + color: "#f9fafb", 892 953 }, 893 954 modalActions: { 894 955 flexDirection: "row", 895 - gap: spacing.md, 896 - marginTop: spacing.md, 956 + gap: 12, 957 + marginTop: 8, 958 + }, 959 + modalActionsSplit: { 960 + flexDirection: "row", 961 + justifyContent: "space-between", 962 + gap: 12, 963 + marginTop: 8, 897 964 }, 898 965 historyList: { 899 966 maxHeight: 300, ··· 902 969 flexDirection: "row", 903 970 justifyContent: "space-between", 904 971 alignItems: "center", 905 - padding: spacing.md, 906 - backgroundColor: colors.cardMuted, 907 - borderRadius: borderRadius.lg, 908 - marginBottom: spacing.sm, 972 + padding: 12, 973 + backgroundColor: "#1f2937", 974 + borderRadius: 8, 975 + marginBottom: 8, 909 976 }, 910 977 historyDate: { 911 978 fontSize: 14, ··· 913 980 fontWeight: "500", 914 981 }, 915 982 historyDeleteButton: { 916 - padding: spacing.sm, 983 + padding: 8, 917 984 }, 918 985 emptyHistory: { 919 986 textAlign: "center", 920 - color: colors.textMuted, 921 - padding: spacing.xl, 987 + color: "#6b7280", 988 + padding: 32, 922 989 }, 923 990 });
+96
apps/mobile/contexts/toast.tsx
··· 1 + import { createContext, useContext, useState, useCallback, useRef, type ReactNode } from "react"; 2 + import { Animated, Text, StyleSheet } from "react-native"; 3 + import { colors, spacing, borderRadius } from "@/constants/theme"; 4 + 5 + interface Toast { 6 + id: string; 7 + message: string; 8 + type: "success" | "error" | "info"; 9 + } 10 + 11 + interface ToastContextType { 12 + showToast: (message: string, type?: "success" | "error" | "info") => void; 13 + } 14 + 15 + const ToastContext = createContext<ToastContextType | null>(null); 16 + 17 + export function ToastProvider({ children }: { children: ReactNode }) { 18 + const [toast, setToast] = useState<Toast | null>(null); 19 + const fadeAnim = useRef(new Animated.Value(0)).current; 20 + 21 + const showToast = useCallback((message: string, type: "success" | "error" | "info" = "info") => { 22 + const id = Math.random().toString(36).substring(7); 23 + setToast({ id, message, type }); 24 + 25 + Animated.sequence([ 26 + Animated.timing(fadeAnim, { 27 + toValue: 1, 28 + duration: 200, 29 + useNativeDriver: true, 30 + }), 31 + Animated.delay(3000), 32 + Animated.timing(fadeAnim, { 33 + toValue: 0, 34 + duration: 200, 35 + useNativeDriver: true, 36 + }), 37 + ]).start(() => { 38 + setToast(null); 39 + }); 40 + }, [fadeAnim]); 41 + 42 + const getBackgroundColor = (type: string) => { 43 + switch (type) { 44 + case "success": 45 + return "#166534"; 46 + case "error": 47 + return "#991b1b"; 48 + default: 49 + return "#1f2937"; 50 + } 51 + }; 52 + 53 + return ( 54 + <ToastContext.Provider value={{ showToast }}> 55 + {children} 56 + {toast && ( 57 + <Animated.View 58 + style={[ 59 + styles.toastContainer, 60 + { backgroundColor: getBackgroundColor(toast.type) }, 61 + { opacity: fadeAnim }, 62 + ]} 63 + > 64 + <Text style={styles.toastText}>{toast.message}</Text> 65 + </Animated.View> 66 + )} 67 + </ToastContext.Provider> 68 + ); 69 + } 70 + 71 + export function useToast() { 72 + const context = useContext(ToastContext); 73 + if (!context) { 74 + throw new Error("useToast must be used within a ToastProvider"); 75 + } 76 + return context; 77 + } 78 + 79 + const styles = StyleSheet.create({ 80 + toastContainer: { 81 + position: "absolute", 82 + top: 60, 83 + left: 16, 84 + right: 16, 85 + paddingHorizontal: spacing.md, 86 + paddingVertical: spacing.sm, 87 + borderRadius: borderRadius.lg, 88 + zIndex: 9999, 89 + }, 90 + toastText: { 91 + color: colors.text, 92 + fontSize: 14, 93 + fontWeight: "500", 94 + textAlign: "center", 95 + }, 96 + });