@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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +46 -0
- package/src/SquadExperience.tsx +200 -0
- package/src/SquadProvider.tsx +232 -0
- package/src/SquadSportsSDK.ts +286 -0
- package/src/__tests__/DeepLinkHandler.test.ts +101 -0
- package/src/__tests__/ErrorBoundary.test.tsx +161 -0
- package/src/__tests__/EventProcessor.test.ts +241 -0
- package/src/__tests__/PushNotificationHandler.test.ts +91 -0
- package/src/__tests__/SecureStorage.test.ts +62 -0
- package/src/__tests__/SquadSportsSDK.test.ts +278 -0
- package/src/__tests__/VerificationCooldown.test.ts +153 -0
- package/src/components/ErrorBoundary.tsx +129 -0
- package/src/components/SOTD/SOTDComponents.tsx +101 -0
- package/src/components/audio/AudioPlayerRow.tsx +189 -0
- package/src/components/audio/recording/AudioRecording.tsx +232 -0
- package/src/components/communities/CommunityComponents.tsx +78 -0
- package/src/components/dialogs/AllDialogs.tsx +123 -0
- package/src/components/dialogs/ConfirmDialog.tsx +77 -0
- package/src/components/dialogs/PermissionDialog.tsx +132 -0
- package/src/components/dragAndDrop/DragAndDrop.tsx +56 -0
- package/src/components/events/EventComponents.tsx +93 -0
- package/src/components/feed/ChatBannerCard.tsx +94 -0
- package/src/components/feed/FeedLoadingSkeleton.tsx +43 -0
- package/src/components/feed/FreestyleCard.tsx +119 -0
- package/src/components/feed/InterstitialOverlay.tsx +190 -0
- package/src/components/feed/PollCard.tsx +158 -0
- package/src/components/feed/SponsoredContentCard.tsx +118 -0
- package/src/components/freestyle/FreestyleComponents.tsx +148 -0
- package/src/components/index.ts +42 -0
- package/src/components/message/MessageCard.tsx +166 -0
- package/src/components/message/MessageComponents.tsx +143 -0
- package/src/components/poll/PollComponents.tsx +226 -0
- package/src/components/sentinels/Sentinels.tsx +175 -0
- package/src/components/squad/FeedSquad.tsx +54 -0
- package/src/components/toasts/Toasts.tsx +88 -0
- package/src/components/ux/RootUXComponents.tsx +157 -0
- package/src/components/ux/action-sheet/ActionSheet.tsx +67 -0
- package/src/components/ux/bottom-sheet/BottomSheetComponents.tsx +93 -0
- package/src/components/ux/bottom-sheet/SquadBottomSheet.tsx +119 -0
- package/src/components/ux/buttons/Button.tsx +37 -0
- package/src/components/ux/buttons/InfoButton.tsx +24 -0
- package/src/components/ux/buttons/XButton.tsx +27 -0
- package/src/components/ux/carousel/Carousel.tsx +134 -0
- package/src/components/ux/emoji-picker/EmojiReactionPicker.tsx +139 -0
- package/src/components/ux/errors/ErrorHint.tsx +46 -0
- package/src/components/ux/inputs/CodeInput.tsx +121 -0
- package/src/components/ux/inputs/DatePicker.tsx +76 -0
- package/src/components/ux/inputs/MaskedTextInput.tsx +95 -0
- package/src/components/ux/inputs/PhoneNumberInput.tsx +90 -0
- package/src/components/ux/inputs/SearchTextInput.tsx +70 -0
- package/src/components/ux/inputs/TextInput.tsx +58 -0
- package/src/components/ux/layout/AvoidKeyboardScreen.tsx +58 -0
- package/src/components/ux/layout/BlurOverlay.tsx +26 -0
- package/src/components/ux/layout/CrossFade.tsx +30 -0
- package/src/components/ux/layout/DismissKeyboardOnBlur.tsx +14 -0
- package/src/components/ux/layout/LoadingOverlay.tsx +60 -0
- package/src/components/ux/layout/NetworkBanner.tsx +64 -0
- package/src/components/ux/layout/PermissionsCTAContent.tsx +54 -0
- package/src/components/ux/layout/RefreshControl.tsx +7 -0
- package/src/components/ux/layout/Screen.tsx +31 -0
- package/src/components/ux/layout/ScreenHeader.tsx +89 -0
- package/src/components/ux/layout/TabBar.tsx +39 -0
- package/src/components/ux/layout/Toast.tsx +116 -0
- package/src/components/ux/layout/TransparentOverlay.tsx +21 -0
- package/src/components/ux/navigation/BackButton.tsx +29 -0
- package/src/components/ux/navigation/LinkButton.tsx +21 -0
- package/src/components/ux/navigation/UrlButton.tsx +25 -0
- package/src/components/ux/scroll/RefreshableFlatList.tsx +35 -0
- package/src/components/ux/shapes/Shapes.tsx +23 -0
- package/src/components/ux/text/Typography.tsx +28 -0
- package/src/components/ux/user-image/UserImage.tsx +75 -0
- package/src/components/ux/user-image/UserImageVariants.tsx +104 -0
- package/src/components/wallet/WalletComponents.tsx +116 -0
- package/src/contexts/AuthContext.tsx +45 -0
- package/src/contexts/EventProcessorContext.tsx +41 -0
- package/src/contexts/PlayerQueueContext.tsx +95 -0
- package/src/hooks/useAuth.ts +23 -0
- package/src/hooks/useDataRefresh.ts +30 -0
- package/src/hooks/useEventProcessor.ts +6 -0
- package/src/hooks/useImageOptimization.ts +59 -0
- package/src/hooks/useOnboardingStepGuard.ts +36 -0
- package/src/hooks/usePendingNavigation.ts +26 -0
- package/src/hooks/useSquadData.ts +84 -0
- package/src/hooks/useUserCreated.ts +25 -0
- package/src/hooks/useUserUpdate.ts +25 -0
- package/src/hooks/useViewabilityTracker.ts +40 -0
- package/src/index.ts +109 -0
- package/src/navigation/SquadNavigator.tsx +262 -0
- package/src/realtime/DeepLinkHandler.ts +113 -0
- package/src/realtime/EventProcessor.ts +313 -0
- package/src/realtime/NetworkMonitor.ts +84 -0
- package/src/realtime/OfflineQueue.ts +133 -0
- package/src/realtime/PushNotificationHandler.ts +125 -0
- package/src/realtime/useRealtimeSync.ts +84 -0
- package/src/screens/auth/EmailVerificationScreen.tsx +201 -0
- package/src/screens/auth/EnterCodeScreen.tsx +253 -0
- package/src/screens/auth/EnterEmailScreen.tsx +234 -0
- package/src/screens/auth/LandingScreen.tsx +90 -0
- package/src/screens/auth/LoginScreen.tsx +126 -0
- package/src/screens/events/EventScreen.tsx +163 -0
- package/src/screens/freestyle/CommunityFreestyleScreen.tsx +82 -0
- package/src/screens/freestyle/FreestyleCreationScreen.tsx +255 -0
- package/src/screens/freestyle/FreestyleListensScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleReactionsScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleScreen.tsx +64 -0
- package/src/screens/home/HomeScreen.tsx +365 -0
- package/src/screens/home/slivers/SquadCircle.tsx +77 -0
- package/src/screens/invite/InvitationQrCodeScreen.tsx +114 -0
- package/src/screens/invite/InviteScreen.tsx +175 -0
- package/src/screens/messaging/MessagingScreen.tsx +360 -0
- package/src/screens/onboarding/OnboardingAccountSetupScreen.tsx +221 -0
- package/src/screens/onboarding/OnboardingTeamSelectScreen.tsx +215 -0
- package/src/screens/onboarding/OnboardingWelcomeScreen.tsx +93 -0
- package/src/screens/polls/PollResponseScreen.tsx +229 -0
- package/src/screens/polls/PollSummationScreen.tsx +78 -0
- package/src/screens/profile/ProfileScreen.tsx +234 -0
- package/src/screens/settings/BlockedUsersScreen.tsx +139 -0
- package/src/screens/settings/DeleteAccountScreen.tsx +182 -0
- package/src/screens/settings/EditProfileScreen.tsx +154 -0
- package/src/screens/settings/NetworkStatusScreen.tsx +59 -0
- package/src/screens/settings/SettingsScreen.tsx +194 -0
- package/src/screens/squad-line/AddCallTitleScreen.tsx +293 -0
- package/src/screens/wallet/WalletScreen.tsx +174 -0
- package/src/services/AuthStateManager.ts +93 -0
- package/src/services/NavigationService.ts +40 -0
- package/src/services/UserDataManager.ts +59 -0
- package/src/services/UserUpdateService.ts +31 -0
- package/src/services/VerificationStateManager.ts +41 -0
- package/src/squad-line/CallScreen.tsx +158 -0
- package/src/squad-line/IncomingCallOverlay.tsx +113 -0
- package/src/squad-line/SquadLineClient.ts +327 -0
- package/src/squad-line/useSquadLine.ts +80 -0
- package/src/state/audio.ts +38 -0
- package/src/state/client.ts +26 -0
- package/src/state/communities.ts +45 -0
- package/src/state/contacts.ts +28 -0
- package/src/state/device-info.ts +22 -0
- package/src/state/events.ts +16 -0
- package/src/state/features.ts +57 -0
- package/src/state/index.ts +121 -0
- package/src/state/invitations.ts +16 -0
- package/src/state/modal-keys.ts +63 -0
- package/src/state/modal-queue.ts +104 -0
- package/src/state/navigation.ts +34 -0
- package/src/state/permissions.ts +43 -0
- package/src/state/session.ts +223 -0
- package/src/state/squaddie-of-the-day.ts +21 -0
- package/src/state/sync/crdt.ts +70 -0
- package/src/state/sync/dependable.ts +213 -0
- package/src/state/sync/feed-v2.ts +42 -0
- package/src/state/sync/messages.ts +44 -0
- package/src/state/sync/offline-support.ts +42 -0
- package/src/state/sync/polls.ts +37 -0
- package/src/state/sync/refresh.ts +24 -0
- package/src/state/sync/squad-v2.ts +25 -0
- package/src/state/ui.ts +36 -0
- package/src/state/user.ts +46 -0
- package/src/state/wallet.ts +26 -0
- package/src/storage/SecureStorage.ts +77 -0
- package/src/theme/ThemeContext.tsx +159 -0
- 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
|
+
});
|