@buivietphi/skill-mobile-mt 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +96 -40
- package/README.md +77 -40
- package/SKILL.md +762 -54
- package/package.json +1 -1
- package/shared/bug-detection.md +411 -27
- package/shared/code-generation-templates.md +656 -0
- package/shared/code-review.md +899 -37
- package/shared/complex-ui-patterns.md +526 -0
- package/shared/data-flow-patterns.md +422 -0
- package/shared/debugging-intelligence.md +787 -0
- package/shared/error-handling.md +394 -0
- package/shared/i18n-localization.md +426 -0
- package/shared/intent-analysis.md +473 -0
- package/shared/navigation-patterns.md +375 -0
- package/shared/prompt-engineering.md +176 -20
- package/shared/spec-to-code.md +293 -0
- package/shared/storage-patterns.md +312 -0
- package/shared/testing-patterns.md +428 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
# Complex UI Patterns — Production Templates
|
|
2
|
+
|
|
3
|
+
> On-demand module. Loaded when building carousels, gestures, responsive layouts, keyboard handling, or dark mode.
|
|
4
|
+
> Contains runnable code templates for complex UI scenarios.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Image Carousel with Snap + Indicators
|
|
9
|
+
|
|
10
|
+
### React Native (FlatList)
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
// components/ImageCarousel.tsx
|
|
14
|
+
import { FlatList, Dimensions, View, Image, StyleSheet } from 'react-native';
|
|
15
|
+
import { useState, useRef, useCallback } from 'react';
|
|
16
|
+
|
|
17
|
+
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
images: string[];
|
|
21
|
+
height?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ImageCarousel({ images, height = 300 }: Props) {
|
|
25
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
26
|
+
const flatListRef = useRef<FlatList>(null);
|
|
27
|
+
|
|
28
|
+
const onViewableItemsChanged = useCallback(({ viewableItems }: { viewableItems: Array<{ index: number | null }> }) => {
|
|
29
|
+
if (viewableItems[0]?.index != null) setActiveIndex(viewableItems[0].index);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 50 }).current;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<View>
|
|
36
|
+
<FlatList
|
|
37
|
+
ref={flatListRef}
|
|
38
|
+
data={images}
|
|
39
|
+
horizontal
|
|
40
|
+
pagingEnabled
|
|
41
|
+
showsHorizontalScrollIndicator={false}
|
|
42
|
+
onViewableItemsChanged={onViewableItemsChanged}
|
|
43
|
+
viewabilityConfig={viewabilityConfig}
|
|
44
|
+
keyExtractor={(_, i) => `img-${i}`}
|
|
45
|
+
renderItem={({ item }) => (
|
|
46
|
+
<Image
|
|
47
|
+
source={{ uri: item }}
|
|
48
|
+
style={{ width: SCREEN_WIDTH, height }}
|
|
49
|
+
resizeMode="cover"
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
getItemLayout={(_, index) => ({
|
|
53
|
+
length: SCREEN_WIDTH,
|
|
54
|
+
offset: SCREEN_WIDTH * index,
|
|
55
|
+
index,
|
|
56
|
+
})}
|
|
57
|
+
/>
|
|
58
|
+
{/* Indicators */}
|
|
59
|
+
<View style={styles.indicators}>
|
|
60
|
+
{images.map((_, i) => (
|
|
61
|
+
<View
|
|
62
|
+
key={i}
|
|
63
|
+
style={[styles.dot, i === activeIndex && styles.dotActive]}
|
|
64
|
+
/>
|
|
65
|
+
))}
|
|
66
|
+
</View>
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const styles = StyleSheet.create({
|
|
72
|
+
indicators: { flexDirection: 'row', justifyContent: 'center', paddingVertical: 8 },
|
|
73
|
+
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#ccc', marginHorizontal: 4 },
|
|
74
|
+
dotActive: { backgroundColor: '#333', width: 24 },
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Flutter
|
|
79
|
+
|
|
80
|
+
```dart
|
|
81
|
+
// widgets/image_carousel.dart
|
|
82
|
+
class ImageCarousel extends StatefulWidget {
|
|
83
|
+
final List<String> images;
|
|
84
|
+
final double height;
|
|
85
|
+
const ImageCarousel({required this.images, this.height = 300, super.key});
|
|
86
|
+
|
|
87
|
+
@override
|
|
88
|
+
State<ImageCarousel> createState() => _ImageCarouselState();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
class _ImageCarouselState extends State<ImageCarousel> {
|
|
92
|
+
int _current = 0;
|
|
93
|
+
final _controller = PageController();
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
Widget build(BuildContext context) {
|
|
97
|
+
return Column(
|
|
98
|
+
children: [
|
|
99
|
+
SizedBox(
|
|
100
|
+
height: widget.height,
|
|
101
|
+
child: PageView.builder(
|
|
102
|
+
controller: _controller,
|
|
103
|
+
onPageChanged: (i) => setState(() => _current = i),
|
|
104
|
+
itemCount: widget.images.length,
|
|
105
|
+
itemBuilder: (_, i) => Image.network(widget.images[i], fit: BoxFit.cover),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
const SizedBox(height: 8),
|
|
109
|
+
Row(
|
|
110
|
+
mainAxisAlignment: MainAxisAlignment.center,
|
|
111
|
+
children: List.generate(widget.images.length, (i) => AnimatedContainer(
|
|
112
|
+
duration: const Duration(milliseconds: 200),
|
|
113
|
+
width: _current == i ? 24 : 8,
|
|
114
|
+
height: 8,
|
|
115
|
+
margin: const EdgeInsets.symmetric(horizontal: 4),
|
|
116
|
+
decoration: BoxDecoration(
|
|
117
|
+
borderRadius: BorderRadius.circular(4),
|
|
118
|
+
color: _current == i ? Colors.black : Colors.grey[300],
|
|
119
|
+
),
|
|
120
|
+
)),
|
|
121
|
+
),
|
|
122
|
+
],
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Gesture Handling
|
|
131
|
+
|
|
132
|
+
### React Native (Reanimated + Gesture Handler)
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// components/SwipeableCard.tsx — Swipe left to delete, right to archive
|
|
136
|
+
import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS } from 'react-native-reanimated';
|
|
137
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
138
|
+
|
|
139
|
+
interface Props {
|
|
140
|
+
children: React.ReactNode;
|
|
141
|
+
onSwipeLeft?: () => void;
|
|
142
|
+
onSwipeRight?: () => void;
|
|
143
|
+
threshold?: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function SwipeableCard({ children, onSwipeLeft, onSwipeRight, threshold = 100 }: Props) {
|
|
147
|
+
const translateX = useSharedValue(0);
|
|
148
|
+
|
|
149
|
+
const panGesture = Gesture.Pan()
|
|
150
|
+
.onUpdate((e) => {
|
|
151
|
+
translateX.value = e.translationX;
|
|
152
|
+
})
|
|
153
|
+
.onEnd((e) => {
|
|
154
|
+
if (e.translationX < -threshold && onSwipeLeft) {
|
|
155
|
+
runOnJS(onSwipeLeft)();
|
|
156
|
+
} else if (e.translationX > threshold && onSwipeRight) {
|
|
157
|
+
runOnJS(onSwipeRight)();
|
|
158
|
+
}
|
|
159
|
+
translateX.value = withSpring(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
163
|
+
transform: [{ translateX: translateX.value }],
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<GestureDetector gesture={panGesture}>
|
|
168
|
+
<Animated.View style={animatedStyle}>
|
|
169
|
+
{children}
|
|
170
|
+
</Animated.View>
|
|
171
|
+
</GestureDetector>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Long Press with Haptic
|
|
176
|
+
import * as Haptics from 'expo-haptics';
|
|
177
|
+
|
|
178
|
+
const longPressGesture = Gesture.LongPress()
|
|
179
|
+
.minDuration(500)
|
|
180
|
+
.onStart(() => {
|
|
181
|
+
runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Medium);
|
|
182
|
+
runOnJS(onLongPress)();
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Keyboard Handling
|
|
189
|
+
|
|
190
|
+
### React Native — Keyboard Avoidance
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
// components/KeyboardAwareView.tsx
|
|
194
|
+
import { KeyboardAvoidingView, Platform, TouchableWithoutFeedback, Keyboard } from 'react-native';
|
|
195
|
+
|
|
196
|
+
interface Props {
|
|
197
|
+
children: React.ReactNode;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function KeyboardAwareView({ children }: Props) {
|
|
201
|
+
return (
|
|
202
|
+
<KeyboardAvoidingView
|
|
203
|
+
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
204
|
+
style={{ flex: 1 }}
|
|
205
|
+
keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 0} // adjust for header
|
|
206
|
+
>
|
|
207
|
+
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
|
208
|
+
<View style={{ flex: 1 }}>
|
|
209
|
+
{children}
|
|
210
|
+
</View>
|
|
211
|
+
</TouchableWithoutFeedback>
|
|
212
|
+
</KeyboardAvoidingView>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// For ScrollView forms — use KeyboardAwareScrollView
|
|
217
|
+
// npm: react-native-keyboard-aware-scroll-view
|
|
218
|
+
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
|
|
219
|
+
|
|
220
|
+
function FormScreen() {
|
|
221
|
+
return (
|
|
222
|
+
<KeyboardAwareScrollView
|
|
223
|
+
extraScrollHeight={20}
|
|
224
|
+
enableOnAndroid
|
|
225
|
+
keyboardShouldPersistTaps="handled"
|
|
226
|
+
>
|
|
227
|
+
{/* form fields */}
|
|
228
|
+
</KeyboardAwareScrollView>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Responsive Layout
|
|
236
|
+
|
|
237
|
+
### React Native — Tablet + Landscape Support
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
// hooks/useResponsive.ts
|
|
241
|
+
import { useWindowDimensions } from 'react-native';
|
|
242
|
+
|
|
243
|
+
type Breakpoint = 'phone' | 'tablet' | 'desktop';
|
|
244
|
+
|
|
245
|
+
export function useResponsive() {
|
|
246
|
+
const { width, height } = useWindowDimensions();
|
|
247
|
+
|
|
248
|
+
const breakpoint: Breakpoint = width >= 1024 ? 'desktop' : width >= 768 ? 'tablet' : 'phone';
|
|
249
|
+
const isLandscape = width > height;
|
|
250
|
+
const numColumns = breakpoint === 'phone' ? 1 : breakpoint === 'tablet' ? 2 : 3;
|
|
251
|
+
|
|
252
|
+
return { width, height, breakpoint, isLandscape, numColumns };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Usage in list screen:
|
|
256
|
+
function ProductListScreen() {
|
|
257
|
+
const { numColumns } = useResponsive();
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<FlatList
|
|
261
|
+
data={products}
|
|
262
|
+
numColumns={numColumns}
|
|
263
|
+
key={`cols-${numColumns}`} // force re-render on column change
|
|
264
|
+
renderItem={({ item }) => (
|
|
265
|
+
<View style={{ flex: 1 / numColumns, padding: 8 }}>
|
|
266
|
+
<ProductCard product={item} />
|
|
267
|
+
</View>
|
|
268
|
+
)}
|
|
269
|
+
/>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Flutter — Responsive Layout
|
|
275
|
+
|
|
276
|
+
```dart
|
|
277
|
+
// widgets/responsive_layout.dart
|
|
278
|
+
class ResponsiveLayout extends StatelessWidget {
|
|
279
|
+
final Widget phone;
|
|
280
|
+
final Widget? tablet;
|
|
281
|
+
const ResponsiveLayout({required this.phone, this.tablet, super.key});
|
|
282
|
+
|
|
283
|
+
@override
|
|
284
|
+
Widget build(BuildContext context) {
|
|
285
|
+
return LayoutBuilder(builder: (context, constraints) {
|
|
286
|
+
if (constraints.maxWidth >= 768 && tablet != null) return tablet!;
|
|
287
|
+
return phone;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Usage:
|
|
293
|
+
ResponsiveLayout(
|
|
294
|
+
phone: ProductListView(columns: 1),
|
|
295
|
+
tablet: ProductGridView(columns: 3),
|
|
296
|
+
)
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Dark Mode Implementation
|
|
302
|
+
|
|
303
|
+
### React Native — Theme System
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
// theme/ThemeProvider.tsx
|
|
307
|
+
import { createContext, useContext, useMemo } from 'react';
|
|
308
|
+
import { useColorScheme } from 'react-native';
|
|
309
|
+
|
|
310
|
+
const lightColors = {
|
|
311
|
+
background: '#FFFFFF',
|
|
312
|
+
surface: '#F5F5F5',
|
|
313
|
+
text: '#1A1A1A',
|
|
314
|
+
textSecondary: '#666666',
|
|
315
|
+
primary: '#007AFF',
|
|
316
|
+
border: '#E0E0E0',
|
|
317
|
+
error: '#FF3B30',
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const darkColors = {
|
|
321
|
+
background: '#000000', // OLED black
|
|
322
|
+
surface: '#1C1C1E',
|
|
323
|
+
text: '#FFFFFF',
|
|
324
|
+
textSecondary: '#8E8E93',
|
|
325
|
+
primary: '#0A84FF',
|
|
326
|
+
border: '#38383A',
|
|
327
|
+
error: '#FF453A',
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
type ThemeColors = typeof lightColors;
|
|
331
|
+
|
|
332
|
+
interface Theme {
|
|
333
|
+
colors: ThemeColors;
|
|
334
|
+
isDark: boolean;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const ThemeContext = createContext<Theme>({} as Theme);
|
|
338
|
+
|
|
339
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
340
|
+
const colorScheme = useColorScheme();
|
|
341
|
+
const theme = useMemo<Theme>(() => ({
|
|
342
|
+
colors: colorScheme === 'dark' ? darkColors : lightColors,
|
|
343
|
+
isDark: colorScheme === 'dark',
|
|
344
|
+
}), [colorScheme]);
|
|
345
|
+
|
|
346
|
+
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const useTheme = () => useContext(ThemeContext);
|
|
350
|
+
|
|
351
|
+
// Usage in any component:
|
|
352
|
+
// const { colors, isDark } = useTheme();
|
|
353
|
+
// <View style={{ backgroundColor: colors.background }}>
|
|
354
|
+
// <Text style={{ color: colors.text }}>Hello</Text>
|
|
355
|
+
// </View>
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Flutter — Theme Switching
|
|
359
|
+
|
|
360
|
+
```dart
|
|
361
|
+
// theme/app_theme.dart
|
|
362
|
+
class AppTheme {
|
|
363
|
+
static ThemeData light = ThemeData(
|
|
364
|
+
brightness: Brightness.light,
|
|
365
|
+
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
|
366
|
+
useMaterial3: true,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
static ThemeData dark = ThemeData(
|
|
370
|
+
brightness: Brightness.dark,
|
|
371
|
+
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: Brightness.dark),
|
|
372
|
+
scaffoldBackgroundColor: Colors.black, // OLED
|
|
373
|
+
useMaterial3: true,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// main.dart
|
|
378
|
+
MaterialApp(
|
|
379
|
+
theme: AppTheme.light,
|
|
380
|
+
darkTheme: AppTheme.dark,
|
|
381
|
+
themeMode: ThemeMode.system, // or ThemeMode.dark / ThemeMode.light
|
|
382
|
+
)
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Accessibility Implementation
|
|
388
|
+
|
|
389
|
+
### React Native
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
// Accessible button with proper semantics
|
|
393
|
+
<TouchableOpacity
|
|
394
|
+
accessible
|
|
395
|
+
accessibilityRole="button"
|
|
396
|
+
accessibilityLabel="Add to cart"
|
|
397
|
+
accessibilityHint="Double tap to add this product to your shopping cart"
|
|
398
|
+
accessibilityState={{ disabled: !inStock }}
|
|
399
|
+
onPress={handleAddToCart}
|
|
400
|
+
style={[styles.button, !inStock && styles.buttonDisabled]}
|
|
401
|
+
>
|
|
402
|
+
<Text style={styles.buttonText}>{inStock ? 'Add to Cart' : 'Out of Stock'}</Text>
|
|
403
|
+
</TouchableOpacity>
|
|
404
|
+
|
|
405
|
+
// Image with description
|
|
406
|
+
<Image
|
|
407
|
+
source={{ uri: product.images[0] }}
|
|
408
|
+
accessible
|
|
409
|
+
accessibilityLabel={`Product image: ${product.title}`}
|
|
410
|
+
accessibilityRole="image"
|
|
411
|
+
/>
|
|
412
|
+
|
|
413
|
+
// Live region for dynamic content (screen reader announces changes)
|
|
414
|
+
<Text accessibilityLiveRegion="polite">
|
|
415
|
+
{cartCount} items in cart
|
|
416
|
+
</Text>
|
|
417
|
+
|
|
418
|
+
// Minimum touch target: 44x44 points (iOS HIG) / 48x48 dp (Material)
|
|
419
|
+
const styles = StyleSheet.create({
|
|
420
|
+
touchTarget: {
|
|
421
|
+
minWidth: 44,
|
|
422
|
+
minHeight: 44,
|
|
423
|
+
justifyContent: 'center',
|
|
424
|
+
alignItems: 'center',
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Flutter
|
|
430
|
+
|
|
431
|
+
```dart
|
|
432
|
+
// Accessible widget
|
|
433
|
+
Semantics(
|
|
434
|
+
label: 'Add to cart',
|
|
435
|
+
hint: 'Double tap to add this product to your shopping cart',
|
|
436
|
+
button: true,
|
|
437
|
+
enabled: inStock,
|
|
438
|
+
child: ElevatedButton(
|
|
439
|
+
onPressed: inStock ? onAddToCart : null,
|
|
440
|
+
child: Text(inStock ? 'Add to Cart' : 'Out of Stock'),
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
// Image with semantics
|
|
445
|
+
Semantics(
|
|
446
|
+
image: true,
|
|
447
|
+
label: 'Product image: ${product.title}',
|
|
448
|
+
child: Image.network(product.images[0]),
|
|
449
|
+
)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### iOS SwiftUI
|
|
453
|
+
|
|
454
|
+
```swift
|
|
455
|
+
Button(action: addToCart) {
|
|
456
|
+
Text(inStock ? "Add to Cart" : "Out of Stock")
|
|
457
|
+
}
|
|
458
|
+
.disabled(!inStock)
|
|
459
|
+
.accessibilityLabel("Add to cart")
|
|
460
|
+
.accessibilityHint("Double tap to add this product to your shopping cart")
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Android Compose
|
|
464
|
+
|
|
465
|
+
```kotlin
|
|
466
|
+
Button(
|
|
467
|
+
onClick = { addToCart() },
|
|
468
|
+
enabled = inStock,
|
|
469
|
+
modifier = Modifier.semantics {
|
|
470
|
+
contentDescription = "Add to cart"
|
|
471
|
+
}
|
|
472
|
+
) {
|
|
473
|
+
Text(if (inStock) "Add to Cart" else "Out of Stock")
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Skeleton Loading
|
|
480
|
+
|
|
481
|
+
### React Native — Shimmer Skeleton
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
// components/Skeleton.tsx
|
|
485
|
+
import Animated, { useSharedValue, useAnimatedStyle, withRepeat, withTiming } from 'react-native-reanimated';
|
|
486
|
+
import { useEffect } from 'react';
|
|
487
|
+
import { useTheme } from '@/theme/ThemeProvider';
|
|
488
|
+
|
|
489
|
+
interface Props {
|
|
490
|
+
width: number | `${number}%`;
|
|
491
|
+
height: number;
|
|
492
|
+
borderRadius?: number;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function Skeleton({ width, height, borderRadius = 4 }: Props) {
|
|
496
|
+
const { colors } = useTheme();
|
|
497
|
+
const opacity = useSharedValue(0.3);
|
|
498
|
+
|
|
499
|
+
useEffect(() => {
|
|
500
|
+
opacity.value = withRepeat(withTiming(1, { duration: 800 }), -1, true);
|
|
501
|
+
}, []);
|
|
502
|
+
|
|
503
|
+
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<Animated.View
|
|
507
|
+
style={[{ width, height, borderRadius, backgroundColor: colors.border }, style]}
|
|
508
|
+
/>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ProductDetailSkeleton.tsx
|
|
513
|
+
export function ProductDetailSkeleton() {
|
|
514
|
+
return (
|
|
515
|
+
<View style={{ padding: 16 }}>
|
|
516
|
+
<Skeleton width="100%" height={300} borderRadius={12} />
|
|
517
|
+
<View style={{ height: 16 }} />
|
|
518
|
+
<Skeleton width="70%" height={24} />
|
|
519
|
+
<View style={{ height: 8 }} />
|
|
520
|
+
<Skeleton width="30%" height={20} />
|
|
521
|
+
<View style={{ height: 16 }} />
|
|
522
|
+
<Skeleton width="100%" height={80} />
|
|
523
|
+
</View>
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
```
|