/ components / ChapterGuideOverlay.tsx
ChapterGuideOverlay.tsx
1 import React, { useState, useEffect, useRef } from 'react'; 2 import { 3 View, 4 Text, 5 TouchableOpacity, 6 StyleSheet, 7 Animated, 8 } from 'react-native'; 9 import { Ionicons } from '@expo/vector-icons'; 10 import { useSafeAreaInsets } from 'react-native-safe-area-context'; 11 import AsyncStorage from '@react-native-async-storage/async-storage'; 12 13 interface ChapterGuideOverlayProps { 14 visible: boolean; 15 onDismiss: () => void; 16 colors: any; 17 onStepChange: (step: number) => void; 18 hideControls?: () => void; 19 showControls?: () => void; 20 } 21 22 const GUIDE_STORAGE_KEY = 'chapter_guide_seen'; 23 24 export const ChapterGuideOverlay: React.FC<ChapterGuideOverlayProps> = ({ 25 visible, 26 onDismiss, 27 colors, 28 onStepChange, 29 hideControls, 30 showControls, 31 }) => { 32 const insets = useSafeAreaInsets(); 33 const opacity = useRef(new Animated.Value(0)).current; 34 const [currentStep, setCurrentStep] = useState(1); 35 const totalSteps = 4; 36 37 useEffect(() => { 38 if (visible) { 39 Animated.timing(opacity, { 40 toValue: 1, 41 duration: 300, 42 useNativeDriver: true, 43 }).start(); 44 } else { 45 Animated.timing(opacity, { 46 toValue: 0, 47 duration: 200, 48 useNativeDriver: true, 49 }).start(); 50 } 51 }, [visible, opacity]); 52 53 useEffect(() => { 54 onStepChange(currentStep); 55 }, [currentStep, onStepChange]); 56 57 const handleNext = () => { 58 if (currentStep === 1 && hideControls) { 59 hideControls(); 60 } 61 62 if (currentStep < totalSteps) { 63 setCurrentStep((prevStep) => prevStep + 1); 64 } else { 65 handleDismiss(); 66 } 67 }; 68 69 const handleBack = () => { 70 if (currentStep > 1) { 71 const newStep = currentStep - 1; 72 setCurrentStep(newStep); 73 74 // If going back to step 1, show the navigation controls 75 if (newStep === 1 && showControls) { 76 showControls(); 77 } 78 } 79 }; 80 81 const handleDismiss = async () => { 82 try { 83 await AsyncStorage.setItem(GUIDE_STORAGE_KEY, 'true'); 84 } catch (error) { 85 console.error('Error saving guide state:', error); 86 } 87 onDismiss(); 88 }; 89 90 const renderStep = () => { 91 switch (currentStep) { 92 case 1: 93 return ( 94 <> 95 <View style={styles.guideOverlay} pointerEvents="none"> 96 <View style={[styles.navHighlight, { top: insets.top }]} /> 97 </View> 98 <View 99 style={[styles.guideContent, { marginTop: insets.top + 100 }]} 100 > 101 <Text style={[styles.guideTitle, { color: colors.primary }]}> 102 Navigation Controls 103 </Text> 104 <Text style={[styles.guideExplanation, { color: colors.text }]}> 105 Try using the controls at the top: 106 </Text> 107 <View style={styles.guideRow}> 108 <View 109 style={[ 110 styles.iconBubble, 111 { backgroundColor: colors.primary + '20' }, 112 ]} 113 > 114 <Ionicons 115 name="arrow-back" 116 size={24} 117 color={colors.primary} 118 /> 119 </View> 120 <Text style={[styles.guideText, { color: colors.text }]}> 121 Return to details page 122 </Text> 123 </View> 124 <View style={styles.guideRow}> 125 <View 126 style={[ 127 styles.iconBubble, 128 { backgroundColor: colors.primary + '20' }, 129 ]} 130 > 131 <Ionicons name="menu" size={24} color={colors.primary} /> 132 </View> 133 <Text style={[styles.guideText, { color: colors.text }]}> 134 Tap title area to browse all chapters 135 </Text> 136 </View> 137 <View style={styles.guideRow}> 138 <View 139 style={[ 140 styles.iconBubble, 141 { backgroundColor: colors.primary + '20' }, 142 ]} 143 > 144 <Ionicons 145 name="chevron-forward" 146 size={24} 147 color={colors.primary} 148 /> 149 </View> 150 <Text style={[styles.guideText, { color: colors.text }]}> 151 Navigate between chapters 152 </Text> 153 </View> 154 <Text style={[styles.guideNote, { color: colors.text + '80' }]}> 155 Works for both manga and manhwa 156 </Text> 157 </View> 158 </> 159 ); 160 case 2: 161 return ( 162 <> 163 <View style={styles.fullOverlay} pointerEvents="none" /> 164 <View style={styles.centerContentWrapper}> 165 <View style={styles.mainTapArea} pointerEvents="none" /> 166 <View style={styles.guideContent}> 167 <Text style={[styles.guideTitle, { color: colors.primary }]}> 168 Interactive Area 169 </Text> 170 <View style={styles.guideRow}> 171 <View 172 style={[ 173 styles.iconBubble, 174 { backgroundColor: colors.primary + '20' }, 175 ]} 176 > 177 <Ionicons 178 name="hand-left-outline" 179 size={24} 180 color={colors.primary} 181 /> 182 </View> 183 <Text style={[styles.guideText, { color: colors.text }]}> 184 Tapping most of the screen will show/hide navigation 185 controls 186 </Text> 187 </View> 188 <Text style={[styles.guideNote, { color: colors.text + '80' }]}> 189 Great for manga and manhwa reading - tap to toggle controls 190 when needed 191 </Text> 192 </View> 193 </View> 194 </> 195 ); 196 case 3: 197 return ( 198 <> 199 <View style={styles.fullOverlay} pointerEvents="none" /> 200 <View style={styles.centerContentWrapper}> 201 <View style={styles.edgeTapAreas} pointerEvents="none"> 202 <View style={styles.leftEdge} /> 203 <View style={styles.rightEdge} /> 204 </View> 205 <View style={styles.guideContent}> 206 <Text style={[styles.guideTitle, { color: colors.primary }]}> 207 Safe Scroll Zones 208 </Text> 209 <View style={styles.guideRow}> 210 <View 211 style={[ 212 styles.iconBubble, 213 { backgroundColor: colors.primary + '20' }, 214 ]} 215 > 216 <Ionicons 217 name="finger-print-outline" 218 size={24} 219 color={colors.primary} 220 /> 221 </View> 222 <Text style={[styles.guideText, { color: colors.text }]}> 223 The edge areas (60px wide) let you scroll without triggering 224 controls 225 </Text> 226 </View> 227 <Text style={[styles.guideNote, { color: colors.text + '80' }]}> 228 Perfect for long manga and manhwa chapters 229 </Text> 230 </View> 231 </View> 232 </> 233 ); 234 case 4: 235 return ( 236 <> 237 <View style={styles.fullOverlay} pointerEvents="none" /> 238 <View style={styles.centerContentWrapper}> 239 <View style={styles.guideContent}> 240 <Text style={[styles.guideTitle, { color: colors.primary }]}> 241 Ready to Read! 242 </Text> 243 <View style={styles.finalStep}> 244 <Ionicons 245 name="book-outline" 246 size={48} 247 color={colors.primary} 248 /> 249 <Text 250 style={[ 251 styles.guideText, 252 { 253 color: colors.text, 254 textAlign: 'center', 255 marginTop: 16, 256 }, 257 ]} 258 > 259 Enjoy reading your manga and manhwa with these intuitive 260 controls 261 </Text> 262 <Text 263 style={[ 264 styles.guideNote, 265 { 266 color: colors.text + '80', 267 textAlign: 'center', 268 marginTop: 8, 269 }, 270 ]} 271 > 272 This guide won't appear again, but you can reset it in 273 the Debug menu 274 </Text> 275 </View> 276 </View> 277 </View> 278 </> 279 ); 280 default: 281 return null; 282 } 283 }; 284 285 if (!visible) return null; 286 287 return ( 288 <Animated.View 289 style={[ 290 styles.overlay, 291 { opacity, paddingTop: insets.top, paddingBottom: insets.bottom }, 292 ]} 293 pointerEvents={currentStep === 1 ? 'box-none' : 'auto'} 294 > 295 {renderStep()} 296 297 <View style={styles.controlRow}> 298 <View style={styles.navButtons}> 299 {currentStep > 1 && ( 300 <TouchableOpacity 301 style={[styles.backButton, { borderColor: colors.primary }]} 302 onPress={handleBack} 303 activeOpacity={0.7} 304 testID="guide-back-button" 305 > 306 <Ionicons 307 name="arrow-back" 308 size={16} 309 color={colors.primary} 310 style={styles.backIcon} 311 /> 312 <Text style={[styles.backButtonText, { color: colors.primary }]}> 313 Back 314 </Text> 315 </TouchableOpacity> 316 )} 317 <Text style={[styles.stepIndicator, { color: colors.text }]}> 318 {currentStep}/{totalSteps} 319 </Text> 320 </View> 321 322 <TouchableOpacity 323 style={[styles.nextButton, { backgroundColor: colors.primary }]} 324 onPress={handleNext} 325 activeOpacity={0.7} 326 testID="guide-next-button" 327 > 328 <Text style={styles.nextButtonText}> 329 {currentStep < totalSteps ? 'Next' : 'Finish'} 330 </Text> 331 <Ionicons 332 name="arrow-forward" 333 size={16} 334 color="white" 335 style={styles.nextIcon} 336 /> 337 </TouchableOpacity> 338 </View> 339 </Animated.View> 340 ); 341 }; 342 343 // Helper to check if the user has seen the guide before 344 export const hasSeenChapterGuide = async (): Promise<boolean> => { 345 try { 346 const value = await AsyncStorage.getItem(GUIDE_STORAGE_KEY); 347 return value === 'true'; 348 } catch (error) { 349 console.error('Error checking guide state:', error); 350 return false; 351 } 352 }; 353 354 const styles = StyleSheet.create({ 355 overlay: { 356 ...StyleSheet.absoluteFillObject, 357 backgroundColor: 'rgba(0, 0, 0, 0.75)', 358 justifyContent: 'space-between', 359 zIndex: 100, 360 }, 361 fullOverlay: { 362 ...StyleSheet.absoluteFillObject, 363 backgroundColor: 'transparent', 364 }, 365 guideOverlay: { 366 ...StyleSheet.absoluteFillObject, 367 zIndex: 1, 368 }, 369 navHighlight: { 370 height: 56, 371 width: '100%', 372 backgroundColor: 'rgba(255, 255, 255, 0.2)', 373 position: 'absolute', 374 }, 375 centerContentWrapper: { 376 flex: 1, 377 justifyContent: 'center', 378 alignItems: 'center', 379 }, 380 mainTapArea: { 381 position: 'absolute', 382 top: 0, 383 left: 60, // 60px from left 384 right: 60, // 60px from right 385 bottom: 0, 386 backgroundColor: 'rgba(255, 255, 255, 0.15)', 387 borderWidth: 2, 388 borderColor: 'rgba(255, 255, 255, 0.3)', 389 borderStyle: 'dashed', 390 }, 391 edgeTapAreas: { 392 ...StyleSheet.absoluteFillObject, 393 flexDirection: 'row', 394 justifyContent: 'space-between', 395 }, 396 leftEdge: { 397 width: 60, // 60px exactly 398 height: '100%', 399 backgroundColor: 'rgba(255, 255, 255, 0.25)', 400 borderRightWidth: 2, 401 borderColor: 'rgba(255, 255, 255, 0.5)', 402 borderStyle: 'dashed', 403 }, 404 rightEdge: { 405 width: 60, // 60px exactly 406 height: '100%', 407 backgroundColor: 'rgba(255, 255, 255, 0.25)', 408 borderLeftWidth: 2, 409 borderColor: 'rgba(255, 255, 255, 0.5)', 410 borderStyle: 'dashed', 411 }, 412 guideContent: { 413 padding: 24, 414 backgroundColor: 'rgba(0, 0, 0, 0.8)', 415 borderRadius: 16, 416 margin: 16, 417 alignSelf: 'center', 418 maxWidth: 400, 419 width: '90%', 420 zIndex: 5, 421 }, 422 guideTitle: { 423 fontSize: 22, 424 fontWeight: 'bold', 425 marginBottom: 16, 426 textAlign: 'center', 427 }, 428 guideExplanation: { 429 fontSize: 16, 430 marginBottom: 16, 431 textAlign: 'center', 432 }, 433 guideRow: { 434 flexDirection: 'row', 435 alignItems: 'center', 436 marginBottom: 16, 437 }, 438 iconBubble: { 439 width: 40, 440 height: 40, 441 borderRadius: 20, 442 justifyContent: 'center', 443 alignItems: 'center', 444 marginRight: 12, 445 }, 446 guideText: { 447 fontSize: 16, 448 flex: 1, 449 }, 450 guideNote: { 451 fontSize: 14, 452 marginTop: 8, 453 fontStyle: 'italic', 454 }, 455 finalStep: { 456 alignItems: 'center', 457 paddingVertical: 20, 458 }, 459 controlRow: { 460 flexDirection: 'row', 461 justifyContent: 'space-between', 462 alignItems: 'center', 463 padding: 16, 464 paddingBottom: 32, 465 zIndex: 2, 466 }, 467 navButtons: { 468 flexDirection: 'row', 469 alignItems: 'center', 470 }, 471 stepIndicator: { 472 fontSize: 16, 473 fontWeight: 'bold', 474 }, 475 backButton: { 476 flexDirection: 'row', 477 alignItems: 'center', 478 marginRight: 16, 479 paddingHorizontal: 16, 480 paddingVertical: 8, 481 borderRadius: 24, 482 borderWidth: 1, 483 }, 484 backButtonText: { 485 fontSize: 14, 486 fontWeight: '600', 487 }, 488 backIcon: { 489 marginRight: 6, 490 }, 491 nextButton: { 492 paddingHorizontal: 24, 493 paddingVertical: 12, 494 borderRadius: 24, 495 flexDirection: 'row', 496 alignItems: 'center', 497 justifyContent: 'center', 498 minWidth: 120, 499 }, 500 nextButtonText: { 501 color: 'white', 502 fontSize: 16, 503 fontWeight: 'bold', 504 }, 505 nextIcon: { 506 marginLeft: 8, 507 }, 508 });