11// src/features/home/screens/StoryScreen.tsx
22
33import type { NativeStackScreenProps } from '@react-navigation/native-stack'
4- import React , { useRef , useState } from 'react'
5- import { ActivityIndicator , Pressable , View } from 'react-native'
4+ import React , { useCallback , useMemo , useRef , useState } from 'react'
5+ import { Animated , Pressable , StyleSheet , View } from 'react-native'
66import Svg , { Path , Polyline } from 'react-native-svg'
77import WebView from 'react-native-webview'
8+ import type { WebViewNavigation , WebViewProgressEvent } from 'react-native-webview/lib/WebViewTypes'
89import type { RootStackParamList } from '@/navigation/root-param-list'
910import { ROUTES } from '@/navigation/routes'
1011import { ScreenWrapper } from '@/shared/components/ui/ScreenWrapper'
1112import { Text } from '@/shared/components/ui/Text'
13+ import { spacing } from '@/shared/theme/tokens/spacing'
1214import { useTheme } from '@/shared/theme/useTheme'
1315
1416type Props = NativeStackScreenProps < RootStackParamList , typeof ROUTES . HOME_STORY >
1517
18+ // ─── Layout constants (derived from design tokens, never raw numbers) ─────────
19+ const HEADER_HEIGHT = spacing . xxxxxl // 56 — matches ScreenHeader
20+ const ICON_SIZE = spacing . lg // 20
21+ const ICON_STROKE = 2.2
22+ const PROGRESS_BAR_HEIGHT = spacing . xxs // 4
23+
1624const HN_ITEM_BASE = 'https://news.ycombinator.com/item?id='
1725
1826export default function StoryScreen ( { route, navigation } : Props ) {
@@ -26,59 +34,89 @@ export default function StoryScreen({ route, navigation }: Props) {
2634 const uri = url ?? `${ HN_ITEM_BASE } ${ id } `
2735 const displayHost = domain ?? 'news.ycombinator.com'
2836
29- const [ loading , setLoading ] = useState ( true )
37+ const [ isLoading , setIsLoading ] = useState ( true )
3038 const [ canGoBack , setCanGoBack ] = useState ( false )
3139 const webViewRef = useRef < WebView > ( null )
40+ const loadProgress = useRef ( new Animated . Value ( 0 ) ) . current
41+
42+ const iconProps = useMemo (
43+ ( ) => ( {
44+ width : ICON_SIZE ,
45+ height : ICON_SIZE ,
46+ viewBox : '0 0 24 24' ,
47+ fill : 'none' ,
48+ stroke : c . textPrimary ,
49+ strokeWidth : ICON_STROKE ,
50+ strokeLinecap : 'round' as const ,
51+ strokeLinejoin : 'round' as const ,
52+ } ) ,
53+ [ c . textPrimary ] ,
54+ )
3255
33- const iconProps = {
34- width : 20 ,
35- height : 20 ,
36- viewBox : '0 0 24 24' ,
37- fill : 'none' ,
38- stroke : c . textPrimary ,
39- strokeWidth : 2.2 ,
40- strokeLinecap : 'round' as const ,
41- strokeLinejoin : 'round' as const ,
42- }
43-
44- function handleBack ( ) {
56+ const handleBack = useCallback ( ( ) => {
4557 if ( canGoBack ) {
4658 webViewRef . current ?. goBack ( )
4759 } else {
4860 navigation . goBack ( )
4961 }
50- }
62+ } , [ canGoBack , navigation ] )
63+
64+ const handleNavigationStateChange = useCallback (
65+ ( state : WebViewNavigation ) => setCanGoBack ( state . canGoBack ) ,
66+ [ ] ,
67+ )
68+
69+ const handleLoadStart = useCallback ( ( ) => {
70+ loadProgress . setValue ( 0 )
71+ setIsLoading ( true )
72+ } , [ loadProgress ] )
73+
74+ const handleLoadEnd = useCallback ( ( ) => setIsLoading ( false ) , [ ] )
75+
76+ const handleLoadProgress = useCallback (
77+ ( { nativeEvent } : WebViewProgressEvent ) => {
78+ Animated . timing ( loadProgress , {
79+ toValue : nativeEvent . progress ,
80+ duration : 80 ,
81+ useNativeDriver : false ,
82+ } ) . start ( )
83+ } ,
84+ [ loadProgress ] ,
85+ )
86+
87+ const progressWidth = loadProgress . interpolate ( {
88+ inputRange : [ 0 , 1 ] ,
89+ outputRange : [ '0%' , '100%' ] ,
90+ } )
5191
5292 return (
5393 < ScreenWrapper
5494 header = {
5595 < View
56- style = { {
57- height : 52 ,
58- flexDirection : 'row' ,
59- alignItems : 'center' ,
60- paddingHorizontal : sp . md ,
61- backgroundColor : c . background ,
62- borderBottomWidth : 1 ,
63- borderBottomColor : c . border ,
64- gap : sp . xs ,
65- } }
96+ style = { [
97+ styles . header ,
98+ {
99+ height : HEADER_HEIGHT ,
100+ paddingHorizontal : sp . md ,
101+ backgroundColor : c . background ,
102+ borderBottomColor : c . border ,
103+ gap : sp . xs ,
104+ } ,
105+ ] }
66106 >
67- { /* Back / WebView back */ }
68107 < Pressable
69108 onPress = { handleBack }
70- hitSlop = { { top : 8 , bottom : 8 , left : 8 , right : 8 } }
109+ hitSlop = { { top : sp . xs , bottom : sp . xs , left : sp . xs , right : sp . xs } }
71110 accessibilityRole = "button"
72111 accessibilityLabel = { canGoBack ? 'Go back in page' : 'Back to feed' }
73- style = { { padding : sp . xs } }
112+ style = { styles . iconBtn }
74113 >
75114 < Svg { ...iconProps } >
76115 < Polyline points = "15 18 9 12 15 6" />
77116 </ Svg >
78117 </ Pressable >
79118
80- { /* Close (always goes to feed) */ }
81- < View style = { { flex : 1 , alignItems : 'center' } } >
119+ < View style = { styles . titleSlot } >
82120 < Text
83121 style = { [ ty . labelMedium , { color : c . textSecondary } ] }
84122 numberOfLines = { 1 }
@@ -87,17 +125,15 @@ export default function StoryScreen({ route, navigation }: Props) {
87125 </ Text >
88126 </ View >
89127
90- { /* Close button */ }
91128 < Pressable
92129 onPress = { ( ) => navigation . goBack ( ) }
93- hitSlop = { { top : 8 , bottom : 8 , left : 8 , right : 8 } }
130+ hitSlop = { { top : sp . xs , bottom : sp . xs , left : sp . xs , right : sp . xs } }
94131 accessibilityRole = "button"
95132 accessibilityLabel = "Close article"
96- style = { {
97- padding : sp . xs ,
98- backgroundColor : c . surfaceSecondary ,
99- borderRadius : r . pill ,
100- } }
133+ style = { [
134+ styles . iconBtn ,
135+ { backgroundColor : c . surfaceSecondary , borderRadius : r . pill } ,
136+ ] }
101137 >
102138 < Svg { ...iconProps } stroke = { c . textTertiary } >
103139 < Path d = "M18 6 6 18M6 6l12 12" />
@@ -109,47 +145,54 @@ export default function StoryScreen({ route, navigation }: Props) {
109145 < WebView
110146 ref = { webViewRef }
111147 source = { { uri } }
148+ accessibilityLabel = { title }
112149 style = { { flex : 1 , backgroundColor : c . background } }
113- onLoadStart = { ( ) => setLoading ( true ) }
114- onLoadEnd = { ( ) => setLoading ( false ) }
115- onNavigationStateChange = { state => setCanGoBack ( state . canGoBack ) }
150+ onLoadStart = { handleLoadStart }
151+ onLoadEnd = { handleLoadEnd }
152+ onLoadProgress = { handleLoadProgress }
153+ onNavigationStateChange = { handleNavigationStateChange }
116154 allowsBackForwardNavigationGestures
117155 allowsInlineMediaPlayback
118156 mediaPlaybackRequiresUserAction
119157 startInLoadingState = { false }
120158 />
121159
122- { /* Loading bar */ }
123- { loading && (
124- < View
125- style = { {
126- position : 'absolute' ,
127- top : 0 ,
128- left : 0 ,
129- right : 0 ,
130- height : 3 ,
131- backgroundColor : c . primaryAmbient ,
132- overflow : 'hidden' ,
133- } }
134- >
135- < View
136- style = { {
137- position : 'absolute' ,
138- top : 0 ,
139- left : 0 ,
140- right : 0 ,
141- bottom : 0 ,
160+ { isLoading && (
161+ < Animated . View
162+ pointerEvents = "none"
163+ style = { [
164+ styles . progressBar ,
165+ {
166+ height : PROGRESS_BAR_HEIGHT ,
142167 backgroundColor : c . primary ,
143- opacity : 0.8 ,
144- } }
145- />
146- < ActivityIndicator
147- size = "small"
148- color = { c . primary }
149- style = { { position : 'absolute' , right : sp . md , top : - 8 } }
150- />
151- </ View >
168+ width : progressWidth ,
169+ } ,
170+ ] }
171+ />
152172 ) }
153173 </ ScreenWrapper >
154174 )
155175}
176+
177+ const styles = StyleSheet . create ( {
178+ header : {
179+ flexDirection : 'row' ,
180+ alignItems : 'center' ,
181+ borderBottomWidth : StyleSheet . hairlineWidth ,
182+ } ,
183+ titleSlot : {
184+ flex : 1 ,
185+ alignItems : 'center' ,
186+ } ,
187+ iconBtn : {
188+ width : spacing . xxxl ,
189+ height : spacing . xxxl ,
190+ alignItems : 'center' ,
191+ justifyContent : 'center' ,
192+ } ,
193+ progressBar : {
194+ position : 'absolute' ,
195+ top : 0 ,
196+ left : 0 ,
197+ } ,
198+ } )
0 commit comments