@squad-sports/react-native 1.3.1

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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +46 -0
  4. package/src/SquadExperience.tsx +200 -0
  5. package/src/SquadProvider.tsx +232 -0
  6. package/src/SquadSportsSDK.ts +286 -0
  7. package/src/__tests__/DeepLinkHandler.test.ts +101 -0
  8. package/src/__tests__/ErrorBoundary.test.tsx +161 -0
  9. package/src/__tests__/EventProcessor.test.ts +241 -0
  10. package/src/__tests__/PushNotificationHandler.test.ts +91 -0
  11. package/src/__tests__/SecureStorage.test.ts +62 -0
  12. package/src/__tests__/SquadSportsSDK.test.ts +278 -0
  13. package/src/__tests__/VerificationCooldown.test.ts +153 -0
  14. package/src/components/ErrorBoundary.tsx +129 -0
  15. package/src/components/SOTD/SOTDComponents.tsx +101 -0
  16. package/src/components/audio/AudioPlayerRow.tsx +189 -0
  17. package/src/components/audio/recording/AudioRecording.tsx +232 -0
  18. package/src/components/communities/CommunityComponents.tsx +78 -0
  19. package/src/components/dialogs/AllDialogs.tsx +123 -0
  20. package/src/components/dialogs/ConfirmDialog.tsx +77 -0
  21. package/src/components/dialogs/PermissionDialog.tsx +132 -0
  22. package/src/components/dragAndDrop/DragAndDrop.tsx +56 -0
  23. package/src/components/events/EventComponents.tsx +93 -0
  24. package/src/components/feed/ChatBannerCard.tsx +94 -0
  25. package/src/components/feed/FeedLoadingSkeleton.tsx +43 -0
  26. package/src/components/feed/FreestyleCard.tsx +119 -0
  27. package/src/components/feed/InterstitialOverlay.tsx +190 -0
  28. package/src/components/feed/PollCard.tsx +158 -0
  29. package/src/components/feed/SponsoredContentCard.tsx +118 -0
  30. package/src/components/freestyle/FreestyleComponents.tsx +148 -0
  31. package/src/components/index.ts +42 -0
  32. package/src/components/message/MessageCard.tsx +166 -0
  33. package/src/components/message/MessageComponents.tsx +143 -0
  34. package/src/components/poll/PollComponents.tsx +226 -0
  35. package/src/components/sentinels/Sentinels.tsx +175 -0
  36. package/src/components/squad/FeedSquad.tsx +54 -0
  37. package/src/components/toasts/Toasts.tsx +88 -0
  38. package/src/components/ux/RootUXComponents.tsx +157 -0
  39. package/src/components/ux/action-sheet/ActionSheet.tsx +67 -0
  40. package/src/components/ux/bottom-sheet/BottomSheetComponents.tsx +93 -0
  41. package/src/components/ux/bottom-sheet/SquadBottomSheet.tsx +119 -0
  42. package/src/components/ux/buttons/Button.tsx +37 -0
  43. package/src/components/ux/buttons/InfoButton.tsx +24 -0
  44. package/src/components/ux/buttons/XButton.tsx +27 -0
  45. package/src/components/ux/carousel/Carousel.tsx +134 -0
  46. package/src/components/ux/emoji-picker/EmojiReactionPicker.tsx +139 -0
  47. package/src/components/ux/errors/ErrorHint.tsx +46 -0
  48. package/src/components/ux/inputs/CodeInput.tsx +121 -0
  49. package/src/components/ux/inputs/DatePicker.tsx +76 -0
  50. package/src/components/ux/inputs/MaskedTextInput.tsx +95 -0
  51. package/src/components/ux/inputs/PhoneNumberInput.tsx +90 -0
  52. package/src/components/ux/inputs/SearchTextInput.tsx +70 -0
  53. package/src/components/ux/inputs/TextInput.tsx +58 -0
  54. package/src/components/ux/layout/AvoidKeyboardScreen.tsx +58 -0
  55. package/src/components/ux/layout/BlurOverlay.tsx +26 -0
  56. package/src/components/ux/layout/CrossFade.tsx +30 -0
  57. package/src/components/ux/layout/DismissKeyboardOnBlur.tsx +14 -0
  58. package/src/components/ux/layout/LoadingOverlay.tsx +60 -0
  59. package/src/components/ux/layout/NetworkBanner.tsx +64 -0
  60. package/src/components/ux/layout/PermissionsCTAContent.tsx +54 -0
  61. package/src/components/ux/layout/RefreshControl.tsx +7 -0
  62. package/src/components/ux/layout/Screen.tsx +31 -0
  63. package/src/components/ux/layout/ScreenHeader.tsx +89 -0
  64. package/src/components/ux/layout/TabBar.tsx +39 -0
  65. package/src/components/ux/layout/Toast.tsx +116 -0
  66. package/src/components/ux/layout/TransparentOverlay.tsx +21 -0
  67. package/src/components/ux/navigation/BackButton.tsx +29 -0
  68. package/src/components/ux/navigation/LinkButton.tsx +21 -0
  69. package/src/components/ux/navigation/UrlButton.tsx +25 -0
  70. package/src/components/ux/scroll/RefreshableFlatList.tsx +35 -0
  71. package/src/components/ux/shapes/Shapes.tsx +23 -0
  72. package/src/components/ux/text/Typography.tsx +28 -0
  73. package/src/components/ux/user-image/UserImage.tsx +75 -0
  74. package/src/components/ux/user-image/UserImageVariants.tsx +104 -0
  75. package/src/components/wallet/WalletComponents.tsx +116 -0
  76. package/src/contexts/AuthContext.tsx +45 -0
  77. package/src/contexts/EventProcessorContext.tsx +41 -0
  78. package/src/contexts/PlayerQueueContext.tsx +95 -0
  79. package/src/hooks/useAuth.ts +23 -0
  80. package/src/hooks/useDataRefresh.ts +30 -0
  81. package/src/hooks/useEventProcessor.ts +6 -0
  82. package/src/hooks/useImageOptimization.ts +59 -0
  83. package/src/hooks/useOnboardingStepGuard.ts +36 -0
  84. package/src/hooks/usePendingNavigation.ts +26 -0
  85. package/src/hooks/useSquadData.ts +84 -0
  86. package/src/hooks/useUserCreated.ts +25 -0
  87. package/src/hooks/useUserUpdate.ts +25 -0
  88. package/src/hooks/useViewabilityTracker.ts +40 -0
  89. package/src/index.ts +109 -0
  90. package/src/navigation/SquadNavigator.tsx +262 -0
  91. package/src/realtime/DeepLinkHandler.ts +113 -0
  92. package/src/realtime/EventProcessor.ts +313 -0
  93. package/src/realtime/NetworkMonitor.ts +84 -0
  94. package/src/realtime/OfflineQueue.ts +133 -0
  95. package/src/realtime/PushNotificationHandler.ts +125 -0
  96. package/src/realtime/useRealtimeSync.ts +84 -0
  97. package/src/screens/auth/EmailVerificationScreen.tsx +201 -0
  98. package/src/screens/auth/EnterCodeScreen.tsx +253 -0
  99. package/src/screens/auth/EnterEmailScreen.tsx +234 -0
  100. package/src/screens/auth/LandingScreen.tsx +90 -0
  101. package/src/screens/auth/LoginScreen.tsx +126 -0
  102. package/src/screens/events/EventScreen.tsx +163 -0
  103. package/src/screens/freestyle/CommunityFreestyleScreen.tsx +82 -0
  104. package/src/screens/freestyle/FreestyleCreationScreen.tsx +255 -0
  105. package/src/screens/freestyle/FreestyleListensScreen.tsx +44 -0
  106. package/src/screens/freestyle/FreestyleReactionsScreen.tsx +44 -0
  107. package/src/screens/freestyle/FreestyleScreen.tsx +64 -0
  108. package/src/screens/home/HomeScreen.tsx +365 -0
  109. package/src/screens/home/slivers/SquadCircle.tsx +77 -0
  110. package/src/screens/invite/InvitationQrCodeScreen.tsx +114 -0
  111. package/src/screens/invite/InviteScreen.tsx +175 -0
  112. package/src/screens/messaging/MessagingScreen.tsx +360 -0
  113. package/src/screens/onboarding/OnboardingAccountSetupScreen.tsx +221 -0
  114. package/src/screens/onboarding/OnboardingTeamSelectScreen.tsx +215 -0
  115. package/src/screens/onboarding/OnboardingWelcomeScreen.tsx +93 -0
  116. package/src/screens/polls/PollResponseScreen.tsx +229 -0
  117. package/src/screens/polls/PollSummationScreen.tsx +78 -0
  118. package/src/screens/profile/ProfileScreen.tsx +234 -0
  119. package/src/screens/settings/BlockedUsersScreen.tsx +139 -0
  120. package/src/screens/settings/DeleteAccountScreen.tsx +182 -0
  121. package/src/screens/settings/EditProfileScreen.tsx +154 -0
  122. package/src/screens/settings/NetworkStatusScreen.tsx +59 -0
  123. package/src/screens/settings/SettingsScreen.tsx +194 -0
  124. package/src/screens/squad-line/AddCallTitleScreen.tsx +293 -0
  125. package/src/screens/wallet/WalletScreen.tsx +174 -0
  126. package/src/services/AuthStateManager.ts +93 -0
  127. package/src/services/NavigationService.ts +40 -0
  128. package/src/services/UserDataManager.ts +59 -0
  129. package/src/services/UserUpdateService.ts +31 -0
  130. package/src/services/VerificationStateManager.ts +41 -0
  131. package/src/squad-line/CallScreen.tsx +158 -0
  132. package/src/squad-line/IncomingCallOverlay.tsx +113 -0
  133. package/src/squad-line/SquadLineClient.ts +327 -0
  134. package/src/squad-line/useSquadLine.ts +80 -0
  135. package/src/state/audio.ts +38 -0
  136. package/src/state/client.ts +26 -0
  137. package/src/state/communities.ts +45 -0
  138. package/src/state/contacts.ts +28 -0
  139. package/src/state/device-info.ts +22 -0
  140. package/src/state/events.ts +16 -0
  141. package/src/state/features.ts +57 -0
  142. package/src/state/index.ts +121 -0
  143. package/src/state/invitations.ts +16 -0
  144. package/src/state/modal-keys.ts +63 -0
  145. package/src/state/modal-queue.ts +104 -0
  146. package/src/state/navigation.ts +34 -0
  147. package/src/state/permissions.ts +43 -0
  148. package/src/state/session.ts +223 -0
  149. package/src/state/squaddie-of-the-day.ts +21 -0
  150. package/src/state/sync/crdt.ts +70 -0
  151. package/src/state/sync/dependable.ts +213 -0
  152. package/src/state/sync/feed-v2.ts +42 -0
  153. package/src/state/sync/messages.ts +44 -0
  154. package/src/state/sync/offline-support.ts +42 -0
  155. package/src/state/sync/polls.ts +37 -0
  156. package/src/state/sync/refresh.ts +24 -0
  157. package/src/state/sync/squad-v2.ts +25 -0
  158. package/src/state/ui.ts +36 -0
  159. package/src/state/user.ts +46 -0
  160. package/src/state/wallet.ts +26 -0
  161. package/src/storage/SecureStorage.ts +77 -0
  162. package/src/theme/ThemeContext.tsx +159 -0
  163. package/src/types/modules.d.ts +165 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Tests for the verification code resend cooldown logic.
3
+ * This tests the pure timing logic extracted from EnterCodeScreen
4
+ * without requiring a full React Native rendering environment.
5
+ */
6
+
7
+ describe('Verification Cooldown Logic', () => {
8
+ beforeEach(() => {
9
+ jest.useFakeTimers();
10
+ });
11
+
12
+ afterEach(() => {
13
+ jest.useRealTimers();
14
+ });
15
+
16
+ const COOLDOWN_SECONDS = 60;
17
+
18
+ /**
19
+ * Simulates the cooldown logic from EnterCodeScreen:
20
+ * - startCooldown sets countdown to 60
21
+ * - An interval decrements every second
22
+ * - At 0, the interval is cleared
23
+ */
24
+ function createCooldownController() {
25
+ let resendCooldown = 0;
26
+ let intervalId: ReturnType<typeof setInterval> | null = null;
27
+
28
+ function startCooldown() {
29
+ resendCooldown = COOLDOWN_SECONDS;
30
+ if (intervalId) clearInterval(intervalId);
31
+ intervalId = setInterval(() => {
32
+ resendCooldown -= 1;
33
+ if (resendCooldown <= 0) {
34
+ resendCooldown = 0;
35
+ if (intervalId) clearInterval(intervalId);
36
+ intervalId = null;
37
+ }
38
+ }, 1000);
39
+ }
40
+
41
+ function cleanup() {
42
+ if (intervalId) clearInterval(intervalId);
43
+ intervalId = null;
44
+ }
45
+
46
+ return {
47
+ startCooldown,
48
+ cleanup,
49
+ getCooldown: () => resendCooldown,
50
+ isIntervalActive: () => intervalId !== null,
51
+ };
52
+ }
53
+
54
+ test('cooldown starts at 60 seconds', () => {
55
+ const ctrl = createCooldownController();
56
+ ctrl.startCooldown();
57
+ expect(ctrl.getCooldown()).toBe(60);
58
+ ctrl.cleanup();
59
+ });
60
+
61
+ test('cooldown decrements each second', () => {
62
+ const ctrl = createCooldownController();
63
+ ctrl.startCooldown();
64
+
65
+ jest.advanceTimersByTime(1000);
66
+ expect(ctrl.getCooldown()).toBe(59);
67
+
68
+ jest.advanceTimersByTime(1000);
69
+ expect(ctrl.getCooldown()).toBe(58);
70
+
71
+ ctrl.cleanup();
72
+ });
73
+
74
+ test('cooldown reaches 0 and stops', () => {
75
+ const ctrl = createCooldownController();
76
+ ctrl.startCooldown();
77
+
78
+ jest.advanceTimersByTime(60 * 1000);
79
+ expect(ctrl.getCooldown()).toBe(0);
80
+ expect(ctrl.isIntervalActive()).toBe(false);
81
+ });
82
+
83
+ test('button is disabled while cooldown > 0', () => {
84
+ const ctrl = createCooldownController();
85
+ ctrl.startCooldown();
86
+
87
+ const isDisabled = () => ctrl.getCooldown() > 0;
88
+
89
+ expect(isDisabled()).toBe(true);
90
+
91
+ jest.advanceTimersByTime(30_000);
92
+ expect(isDisabled()).toBe(true);
93
+ expect(ctrl.getCooldown()).toBe(30);
94
+
95
+ jest.advanceTimersByTime(30_000);
96
+ expect(isDisabled()).toBe(false);
97
+
98
+ ctrl.cleanup();
99
+ });
100
+
101
+ test('cleanup stops the interval', () => {
102
+ const ctrl = createCooldownController();
103
+ ctrl.startCooldown();
104
+
105
+ jest.advanceTimersByTime(5000);
106
+ expect(ctrl.getCooldown()).toBe(55);
107
+
108
+ ctrl.cleanup();
109
+
110
+ // After cleanup, advancing time should not change the cooldown
111
+ const cooldownAtCleanup = ctrl.getCooldown();
112
+ jest.advanceTimersByTime(10_000);
113
+ expect(ctrl.getCooldown()).toBe(cooldownAtCleanup);
114
+ });
115
+
116
+ test('restarting cooldown resets to 60', () => {
117
+ const ctrl = createCooldownController();
118
+ ctrl.startCooldown();
119
+
120
+ jest.advanceTimersByTime(20_000);
121
+ expect(ctrl.getCooldown()).toBe(40);
122
+
123
+ // Restart
124
+ ctrl.startCooldown();
125
+ expect(ctrl.getCooldown()).toBe(60);
126
+
127
+ jest.advanceTimersByTime(1000);
128
+ expect(ctrl.getCooldown()).toBe(59);
129
+
130
+ ctrl.cleanup();
131
+ });
132
+
133
+ test('countdown display format', () => {
134
+ const ctrl = createCooldownController();
135
+ ctrl.startCooldown();
136
+
137
+ // Mimics the display logic from EnterCodeScreen
138
+ const getDisplayText = () => {
139
+ const cd = ctrl.getCooldown();
140
+ return cd > 0 ? ` Resend (${cd}s)` : ' Resend';
141
+ };
142
+
143
+ expect(getDisplayText()).toBe(' Resend (60s)');
144
+
145
+ jest.advanceTimersByTime(45_000);
146
+ expect(getDisplayText()).toBe(' Resend (15s)');
147
+
148
+ jest.advanceTimersByTime(15_000);
149
+ expect(getDisplayText()).toBe(' Resend');
150
+
151
+ ctrl.cleanup();
152
+ });
153
+ });
@@ -0,0 +1,129 @@
1
+ import React, { Component, type ErrorInfo, type ReactNode } from 'react';
2
+ import { View, Text, StyleSheet, Pressable } from 'react-native';
3
+ import { Colors } from '../theme/ThemeContext';
4
+
5
+ interface ErrorBoundaryProps {
6
+ children: ReactNode;
7
+ fallback?: ReactNode;
8
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
9
+ screenName?: string;
10
+ }
11
+
12
+ interface ErrorBoundaryState {
13
+ hasError: boolean;
14
+ error: Error | null;
15
+ }
16
+
17
+ /**
18
+ * Catches React rendering errors and shows a recovery UI.
19
+ * Wrap individual screens so one crash doesn't kill the whole app.
20
+ *
21
+ * Usage:
22
+ * ```tsx
23
+ * <ScreenErrorBoundary screenName="Home">
24
+ * <HomeScreen />
25
+ * </ScreenErrorBoundary>
26
+ * ```
27
+ */
28
+ export class ScreenErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
29
+ state: ErrorBoundaryState = { hasError: false, error: null };
30
+
31
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
32
+ return { hasError: true, error };
33
+ }
34
+
35
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
36
+ console.error(`[ErrorBoundary:${this.props.screenName ?? 'unknown'}]`, error, errorInfo);
37
+ this.props.onError?.(error, errorInfo);
38
+ }
39
+
40
+ handleRetry = () => {
41
+ this.setState({ hasError: false, error: null });
42
+ };
43
+
44
+ render() {
45
+ if (this.state.hasError) {
46
+ if (this.props.fallback) {
47
+ return this.props.fallback;
48
+ }
49
+
50
+ return (
51
+ <View style={styles.container} accessibilityRole="alert">
52
+ <View style={styles.content}>
53
+ <Text style={styles.icon} accessibilityLabel="Error icon">!</Text>
54
+ <Text style={styles.title} accessibilityRole="header">
55
+ Something went wrong
56
+ </Text>
57
+ <Text style={styles.message}>
58
+ {this.props.screenName
59
+ ? `There was a problem loading ${this.props.screenName}.`
60
+ : 'An unexpected error occurred.'}
61
+ </Text>
62
+ <Pressable
63
+ style={styles.retryButton}
64
+ onPress={this.handleRetry}
65
+ accessibilityRole="button"
66
+ accessibilityLabel="Try again"
67
+ >
68
+ <Text style={styles.retryText}>Try Again</Text>
69
+ </Pressable>
70
+ </View>
71
+ </View>
72
+ );
73
+ }
74
+
75
+ return this.props.children;
76
+ }
77
+ }
78
+
79
+ const styles = StyleSheet.create({
80
+ container: {
81
+ flex: 1,
82
+ backgroundColor: Colors.gray9,
83
+ justifyContent: 'center',
84
+ alignItems: 'center',
85
+ padding: 32,
86
+ },
87
+ content: {
88
+ alignItems: 'center',
89
+ maxWidth: 300,
90
+ },
91
+ icon: {
92
+ fontSize: 40,
93
+ fontWeight: '700',
94
+ color: Colors.orange1,
95
+ width: 64,
96
+ height: 64,
97
+ lineHeight: 64,
98
+ textAlign: 'center',
99
+ backgroundColor: 'rgba(233, 120, 92, 0.14)',
100
+ borderRadius: 32,
101
+ overflow: 'hidden',
102
+ marginBottom: 20,
103
+ },
104
+ title: {
105
+ fontSize: 20,
106
+ fontWeight: '600',
107
+ color: Colors.white,
108
+ marginBottom: 8,
109
+ textAlign: 'center',
110
+ },
111
+ message: {
112
+ fontSize: 14,
113
+ color: Colors.gray6,
114
+ textAlign: 'center',
115
+ marginBottom: 24,
116
+ lineHeight: 20,
117
+ },
118
+ retryButton: {
119
+ paddingVertical: 12,
120
+ paddingHorizontal: 32,
121
+ borderRadius: 20,
122
+ backgroundColor: Colors.purple1,
123
+ },
124
+ retryText: {
125
+ color: Colors.gray1,
126
+ fontWeight: '600',
127
+ fontSize: 15,
128
+ },
129
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Squaddie of the Day (SOTD) components.
3
+ * Ported from squad-demo/src/components/SOTD/*.
4
+ */
5
+ import React, { memo } from 'react';
6
+ import { View, Text, StyleSheet, Pressable, Animated } from 'react-native';
7
+ import { Colors } from '../../theme/ThemeContext';
8
+ import UserImage from '../ux/user-image/UserImage';
9
+ import { TitleSmall, BodyRegular, BodySmall } from '../ux/text/Typography';
10
+ import Button from '../ux/buttons/Button';
11
+
12
+ // --- SotdButton (locked/unlocked states) ---
13
+ export function SotdButton({
14
+ isUnlocked, userName, userImageUrl, onPress, primaryColor = Colors.purple1,
15
+ }: { isUnlocked: boolean; userName?: string; userImageUrl?: string; onPress: () => void; primaryColor?: string }) {
16
+ return (
17
+ <Pressable style={[styles.sotdButton, { borderColor: primaryColor }]} onPress={onPress} accessibilityRole="button" accessibilityLabel="Squaddie of the Day">
18
+ {isUnlocked && userImageUrl ? (
19
+ <UserImage imageUrl={userImageUrl} displayName={userName} size={44} />
20
+ ) : (
21
+ <View style={[styles.sotdLocked, { backgroundColor: primaryColor }]}>
22
+ <Text style={styles.sotdLockedText}>SOTD</Text>
23
+ </View>
24
+ )}
25
+ </Pressable>
26
+ );
27
+ }
28
+
29
+ // --- CurrentSotdUser ---
30
+ export function CurrentSotdUser({ userName, userImageUrl }: { userName: string; userImageUrl?: string }) {
31
+ return (
32
+ <View style={styles.currentSotd}>
33
+ <UserImage imageUrl={userImageUrl} displayName={userName} size={64} />
34
+ <TitleSmall style={styles.currentSotdName}>{userName}</TitleSmall>
35
+ <BodySmall style={styles.currentSotdLabel}>Squaddie of the Day</BodySmall>
36
+ </View>
37
+ );
38
+ }
39
+
40
+ // --- SOTD Intro Bottom Sheet ---
41
+ export function SOTDIntroBottomSheet({ onStart, onDismiss }: { onStart: () => void; onDismiss: () => void }) {
42
+ return (
43
+ <View style={styles.introSheet}>
44
+ <Text style={styles.introEmoji}>trophy</Text>
45
+ <TitleSmall style={styles.introTitle}>Squaddie of the Day</TitleSmall>
46
+ <BodyRegular style={styles.introDesc}>
47
+ Each day, one member of your squad gets the spotlight. Claim your moment!
48
+ </BodyRegular>
49
+ <Button style={styles.introButton} onPress={onStart}>
50
+ <Text style={styles.introButtonText}>Let's Go!</Text>
51
+ </Button>
52
+ </View>
53
+ );
54
+ }
55
+
56
+ // --- SOTD Selecting Bottom Sheet ---
57
+ export function SOTDSelectingBottomSheet({
58
+ connections, onSelect, onDismiss,
59
+ }: { connections: Array<{ id: string; name: string; imageUrl?: string }>; onSelect: (id: string) => void; onDismiss: () => void }) {
60
+ return (
61
+ <View style={styles.selectSheet}>
62
+ <TitleSmall style={styles.selectTitle}>Choose your Squaddie</TitleSmall>
63
+ {connections.map(c => (
64
+ <Pressable key={c.id} style={styles.selectRow} onPress={() => onSelect(c.id)}>
65
+ <UserImage imageUrl={c.imageUrl} displayName={c.name} size={44} />
66
+ <BodyRegular style={styles.selectName}>{c.name}</BodyRegular>
67
+ </Pressable>
68
+ ))}
69
+ </View>
70
+ );
71
+ }
72
+
73
+ // --- SOTDTag ---
74
+ export function SOTDTag() {
75
+ return (
76
+ <View style={styles.sotdTag}>
77
+ <Text style={styles.sotdTagText}>SOTD</Text>
78
+ </View>
79
+ );
80
+ }
81
+
82
+ const styles = StyleSheet.create({
83
+ sotdButton: { width: 52, height: 52, borderRadius: 26, borderWidth: 2, justifyContent: 'center', alignItems: 'center', overflow: 'hidden' },
84
+ sotdLocked: { width: 44, height: 44, borderRadius: 22, justifyContent: 'center', alignItems: 'center' },
85
+ sotdLockedText: { color: Colors.white, fontSize: 10, fontWeight: '800' },
86
+ currentSotd: { alignItems: 'center', padding: 16 },
87
+ currentSotdName: { color: Colors.white, marginTop: 8 },
88
+ currentSotdLabel: { color: Colors.gold, marginTop: 2 },
89
+ introSheet: { padding: 24, alignItems: 'center' },
90
+ introEmoji: { fontSize: 48, marginBottom: 16, color: Colors.gold },
91
+ introTitle: { color: Colors.white, marginBottom: 8 },
92
+ introDesc: { color: Colors.gray6, textAlign: 'center', marginBottom: 24 },
93
+ introButton: { paddingVertical: 14, paddingHorizontal: 32, borderRadius: 24, backgroundColor: Colors.purple1 },
94
+ introButtonText: { color: Colors.gray1, fontWeight: '600', fontSize: 15 },
95
+ selectSheet: { padding: 16 },
96
+ selectTitle: { color: Colors.white, marginBottom: 16 },
97
+ selectRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.gray3 },
98
+ selectName: { color: Colors.white, flex: 1 },
99
+ sotdTag: { backgroundColor: Colors.gold, borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2 },
100
+ sotdTagText: { color: Colors.gray1, fontSize: 8, fontWeight: '800' },
101
+ });
@@ -0,0 +1,189 @@
1
+ import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
2
+ import { View, StyleSheet, Pressable, Animated } from 'react-native';
3
+ import { Audio, AVPlaybackStatus } from 'expo-av';
4
+ import { Colors } from '../../theme/ThemeContext';
5
+ import { BodySmall } from '../ux/text/Typography';
6
+
7
+ interface AudioPlayerRowProps {
8
+ audioUrl: string;
9
+ duration?: number;
10
+ }
11
+
12
+ function AudioPlayerRow({ audioUrl, duration: initialDuration }: AudioPlayerRowProps) {
13
+ const [isPlaying, setIsPlaying] = useState(false);
14
+ const [position, setPosition] = useState(0);
15
+ const [duration, setDuration] = useState(initialDuration ?? 0);
16
+ const [isLoaded, setIsLoaded] = useState(false);
17
+ const soundRef = useRef<Audio.Sound | null>(null);
18
+ const progressAnim = useRef(new Animated.Value(0)).current;
19
+
20
+ useEffect(() => {
21
+ return () => {
22
+ soundRef.current?.unloadAsync().catch(() => {});
23
+ };
24
+ }, []);
25
+
26
+ const loadSound = useCallback(async () => {
27
+ if (soundRef.current) return soundRef.current;
28
+
29
+ const { sound } = await Audio.Sound.createAsync(
30
+ { uri: audioUrl },
31
+ { shouldPlay: false },
32
+ onPlaybackStatusUpdate,
33
+ );
34
+ soundRef.current = sound;
35
+ setIsLoaded(true);
36
+ return sound;
37
+ }, [audioUrl]);
38
+
39
+ const onPlaybackStatusUpdate = useCallback((status: AVPlaybackStatus) => {
40
+ if (!status.isLoaded) return;
41
+
42
+ setPosition(status.positionMillis);
43
+ if (status.durationMillis) {
44
+ setDuration(status.durationMillis);
45
+ }
46
+
47
+ const progress = status.durationMillis
48
+ ? status.positionMillis / status.durationMillis
49
+ : 0;
50
+
51
+ progressAnim.setValue(progress);
52
+
53
+ if (status.didJustFinish) {
54
+ setIsPlaying(false);
55
+ setPosition(0);
56
+ progressAnim.setValue(0);
57
+ }
58
+ }, [progressAnim]);
59
+
60
+ const togglePlayback = useCallback(async () => {
61
+ try {
62
+ const sound = await loadSound();
63
+
64
+ if (isPlaying) {
65
+ await sound.pauseAsync();
66
+ setIsPlaying(false);
67
+ } else {
68
+ await Audio.setAudioModeAsync({ playsInSilentModeIOS: true });
69
+ await sound.playAsync();
70
+ setIsPlaying(true);
71
+ }
72
+ } catch (err) {
73
+ console.error('[AudioPlayer] Error:', err);
74
+ }
75
+ }, [isPlaying, loadSound]);
76
+
77
+ const formatMs = (ms: number) => {
78
+ const totalSec = Math.floor(ms / 1000);
79
+ const m = Math.floor(totalSec / 60);
80
+ const s = totalSec % 60;
81
+ return `${m}:${s.toString().padStart(2, '0')}`;
82
+ };
83
+
84
+ return (
85
+ <View style={styles.container}>
86
+ <Pressable style={styles.playButton} onPress={togglePlayback}>
87
+ <View style={[styles.playIcon, isPlaying && styles.pauseIcon]}>
88
+ {isPlaying ? (
89
+ <View style={styles.pauseBars}>
90
+ <View style={styles.pauseBar} />
91
+ <View style={styles.pauseBar} />
92
+ </View>
93
+ ) : (
94
+ <View style={styles.playTriangle} />
95
+ )}
96
+ </View>
97
+ </Pressable>
98
+
99
+ <View style={styles.waveformContainer}>
100
+ <View style={styles.waveformBg}>
101
+ <Animated.View
102
+ style={[
103
+ styles.waveformFill,
104
+ {
105
+ width: progressAnim.interpolate({
106
+ inputRange: [0, 1],
107
+ outputRange: ['0%', '100%'],
108
+ }),
109
+ },
110
+ ]}
111
+ />
112
+ </View>
113
+ </View>
114
+
115
+ <BodySmall style={styles.duration}>
116
+ {formatMs(isPlaying ? position : duration)}
117
+ </BodySmall>
118
+ </View>
119
+ );
120
+ }
121
+
122
+ export { AudioPlayerRow };
123
+ export default memo(AudioPlayerRow);
124
+
125
+ const styles = StyleSheet.create({
126
+ container: {
127
+ flexDirection: 'row',
128
+ alignItems: 'center',
129
+ backgroundColor: Colors.gray3,
130
+ borderRadius: 24,
131
+ paddingVertical: 8,
132
+ paddingHorizontal: 8,
133
+ gap: 10,
134
+ },
135
+ playButton: {
136
+ width: 36,
137
+ height: 36,
138
+ borderRadius: 18,
139
+ backgroundColor: Colors.white,
140
+ justifyContent: 'center',
141
+ alignItems: 'center',
142
+ },
143
+ playIcon: {
144
+ justifyContent: 'center',
145
+ alignItems: 'center',
146
+ },
147
+ pauseIcon: {},
148
+ playTriangle: {
149
+ width: 0,
150
+ height: 0,
151
+ borderLeftWidth: 10,
152
+ borderTopWidth: 6,
153
+ borderBottomWidth: 6,
154
+ borderLeftColor: Colors.gray1,
155
+ borderTopColor: 'transparent',
156
+ borderBottomColor: 'transparent',
157
+ marginLeft: 2,
158
+ },
159
+ pauseBars: {
160
+ flexDirection: 'row',
161
+ gap: 3,
162
+ },
163
+ pauseBar: {
164
+ width: 3,
165
+ height: 14,
166
+ backgroundColor: Colors.gray1,
167
+ borderRadius: 1,
168
+ },
169
+ waveformContainer: {
170
+ flex: 1,
171
+ height: 4,
172
+ },
173
+ waveformBg: {
174
+ flex: 1,
175
+ backgroundColor: Colors.gray5,
176
+ borderRadius: 2,
177
+ overflow: 'hidden',
178
+ },
179
+ waveformFill: {
180
+ height: '100%',
181
+ backgroundColor: Colors.white,
182
+ borderRadius: 2,
183
+ },
184
+ duration: {
185
+ color: Colors.gray6,
186
+ minWidth: 32,
187
+ textAlign: 'right',
188
+ },
189
+ });