@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,365 @@
1
+ import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ FlatList,
7
+ RefreshControl,
8
+ Pressable,
9
+ SectionList,
10
+ Dimensions,
11
+ } from 'react-native';
12
+ import { useNavigation } from '@react-navigation/native';
13
+ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
14
+
15
+ import type { RootStackParamList } from '../../navigation/SquadNavigator';
16
+ import { useApiClient } from '../../SquadProvider';
17
+ import { useTheme, Colors } from '../../theme/ThemeContext';
18
+ import { useRealtimeEvent } from '../../realtime/useRealtimeSync';
19
+ import UserImage from '../../components/ux/user-image/UserImage';
20
+ import FreestyleCard from '../../components/feed/FreestyleCard';
21
+ import PollCard from '../../components/feed/PollCard';
22
+ import SponsoredContentCard from '../../components/feed/SponsoredContentCard';
23
+ import InterstitialOverlay from '../../components/feed/InterstitialOverlay';
24
+ import Button from '../../components/ux/buttons/Button';
25
+ import { TitleSmall, BodyRegular, BodySmall, TitleMedium } from '../../components/ux/text/Typography';
26
+ import type { User, Connection } from '@squad-sports/core';
27
+ import { injectSponsoredContent, type SponsorshipPlacementData } from '@squad-sports/core/src/sponsorship/feed-injector';
28
+ import { ImpressionTracker } from '@squad-sports/core/src/sponsorship/impression-tracker';
29
+ import { InterstitialManager } from '@squad-sports/core/src/sponsorship/interstitial-manager';
30
+
31
+ type Nav = NativeStackNavigationProp<RootStackParamList, 'Home'>;
32
+
33
+ const { width: SCREEN_WIDTH } = Dimensions.get('window');
34
+
35
+ export function HomeScreen() {
36
+ const apiClient = useApiClient();
37
+ const { theme } = useTheme();
38
+ const navigation = useNavigation<Nav>();
39
+
40
+ const [user, setUser] = useState<User | null>(null);
41
+ const [connections, setConnections] = useState<Connection[]>([]);
42
+ const [freestyles, setFreestyles] = useState<any[]>([]);
43
+ const [polls, setPolls] = useState<any[]>([]);
44
+ const [placements, setPlacements] = useState<SponsorshipPlacementData[]>([]);
45
+ const [interstitialPlacement, setInterstitialPlacement] = useState<SponsorshipPlacementData | null>(null);
46
+ const [refreshing, setRefreshing] = useState(false);
47
+
48
+ const interstitialManagerRef = useRef(new InterstitialManager());
49
+
50
+ const loadData = useCallback(async (forceFresh = false) => {
51
+ try {
52
+ const [userData, squad, feed, pollFeed, sponsorships] = await Promise.all([
53
+ apiClient.getLoggedInUser(),
54
+ apiClient.getUserConnections(forceFresh),
55
+ apiClient.getFeed(1, 10, forceFresh).catch(() => null),
56
+ apiClient.getActivePolls(forceFresh).catch(() => null),
57
+ apiClient.getActivePlacements(forceFresh).catch(() => null),
58
+ ]);
59
+ setUser(userData);
60
+ setConnections(squad?.connections ?? []);
61
+ setFreestyles(feed?.freestyles ?? []);
62
+ setPolls(pollFeed?.polls ?? []);
63
+
64
+ const loadedPlacements: SponsorshipPlacementData[] = (sponsorships?.placements ?? []).map((p: any) => ({
65
+ id: p.id,
66
+ type: p.type,
67
+ brandId: p.brandId ?? p.brand?.id ?? '',
68
+ brandName: p.brand?.name ?? '',
69
+ brandImageUrl: p.brand?.imageUrl,
70
+ headline: p.headline,
71
+ bodyText: p.bodyText,
72
+ imageUrl: p.imageUrl,
73
+ ctaText: p.ctaText,
74
+ ctaUrl: p.ctaUrl,
75
+ isHouseAd: p.isHouseAd ?? false,
76
+ config: p.config,
77
+ }));
78
+ setPlacements(loadedPlacements);
79
+ interstitialManagerRef.current.configure(loadedPlacements);
80
+ ImpressionTracker.shared.configure(apiClient);
81
+ } catch (error) {
82
+ console.error('[HomeScreen] Error loading data:', error);
83
+ }
84
+ }, [apiClient]);
85
+
86
+ useEffect(() => {
87
+ loadData();
88
+ }, [loadData]);
89
+
90
+ // Live updates
91
+ useRealtimeEvent('feed:update', () => loadData());
92
+ useRealtimeEvent('poll:update', () => loadData());
93
+ useRealtimeEvent('squad:member:add', () => loadData());
94
+
95
+ // Game moment → interstitial sponsorship
96
+ useRealtimeEvent('game:moment', (data: { type: string }) => {
97
+ const placement = interstitialManagerRef.current.onGameMoment(data.type);
98
+ if (placement) setInterstitialPlacement(placement);
99
+ });
100
+
101
+ const handleImpression = useCallback((placementId: string, durationMs: number) => {
102
+ ImpressionTracker.shared.record(placementId, durationMs, 'feed', false);
103
+ }, []);
104
+
105
+ const handleCtaPress = useCallback((placementId: string) => {
106
+ ImpressionTracker.shared.record(placementId, 0, 'feed', true);
107
+ }, []);
108
+
109
+ const onRefresh = useCallback(async () => {
110
+ setRefreshing(true);
111
+ await loadData(true);
112
+ setRefreshing(false);
113
+ }, [loadData]);
114
+
115
+ const primaryColor = theme.buttonColor;
116
+
117
+ return (
118
+ <View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
119
+ <FlatList
120
+ data={[]}
121
+ renderItem={() => null}
122
+ ListHeaderComponent={
123
+ <>
124
+ {/* Header */}
125
+ <View style={styles.header}>
126
+ <View style={styles.headerLeft}>
127
+ <TitleMedium style={styles.greeting}>
128
+ Hey, {user?.displayName ?? 'there'}
129
+ </TitleMedium>
130
+ <BodySmall style={styles.squadCount}>
131
+ {connections.length} in your squad
132
+ </BodySmall>
133
+ </View>
134
+ <View style={styles.headerRight}>
135
+ <Pressable onPress={() => navigation.navigate('Invite')}>
136
+ <View style={[styles.actionIcon, { borderColor: primaryColor }]}>
137
+ <Text style={[styles.actionIconText, { color: primaryColor }]}>+</Text>
138
+ </View>
139
+ </Pressable>
140
+ <Pressable onPress={() => navigation.navigate('Settings')}>
141
+ <View style={styles.settingsIcon}>
142
+ <Text style={styles.settingsIconText}>...</Text>
143
+ </View>
144
+ </Pressable>
145
+ </View>
146
+ </View>
147
+
148
+ {/* Squad Circle */}
149
+ {connections.length > 0 && (
150
+ <View style={styles.squadCircleSection}>
151
+ <FlatList
152
+ data={connections.slice(0, 8)}
153
+ keyExtractor={(item) => item.id ?? ''}
154
+ horizontal
155
+ showsHorizontalScrollIndicator={false}
156
+ contentContainerStyle={styles.squadCircleList}
157
+ renderItem={({ item }) => {
158
+ const other = item.recipient ?? item.creator;
159
+ return (
160
+ <Pressable
161
+ style={styles.squadMember}
162
+ onPress={() => navigation.navigate('Profile', { userId: other?.id ?? item.id ?? '' })}
163
+ >
164
+ <UserImage
165
+ imageUrl={other?.imageUrl}
166
+ displayName={other?.displayName}
167
+ size={56}
168
+ borderColor={primaryColor}
169
+ />
170
+ <BodySmall style={styles.squadMemberName} numberOfLines={1}>
171
+ {other?.displayName?.split(' ')[0] ?? ''}
172
+ </BodySmall>
173
+ </Pressable>
174
+ );
175
+ }}
176
+ />
177
+ </View>
178
+ )}
179
+
180
+ {/* Action Buttons */}
181
+ <View style={styles.actionRow}>
182
+ <Button
183
+ style={[styles.actionButton, { backgroundColor: Colors.gray2 }]}
184
+ onPress={() => navigation.navigate('FreestyleCreate')}
185
+ >
186
+ <Text style={[styles.actionButtonIcon, { color: primaryColor }]}>{'M'}</Text>
187
+ <BodySmall style={styles.actionButtonLabel}>Freestyle</BodySmall>
188
+ </Button>
189
+ <Button
190
+ style={[styles.actionButton, { backgroundColor: Colors.gray2 }]}
191
+ onPress={() => navigation.navigate('Invite')}
192
+ >
193
+ <Text style={[styles.actionButtonIcon, { color: primaryColor }]}>{'+'}</Text>
194
+ <BodySmall style={styles.actionButtonLabel}>Invite</BodySmall>
195
+ </Button>
196
+ </View>
197
+
198
+ {/* Active Polls */}
199
+ {polls.length > 0 && (
200
+ <View style={styles.section}>
201
+ <TitleSmall style={styles.sectionTitle}>Active Polls</TitleSmall>
202
+ {polls.slice(0, 2).map((poll: any) => (
203
+ <PollCard
204
+ key={poll.id}
205
+ id={poll.id}
206
+ question={poll.question ?? ''}
207
+ options={(poll.options ?? []).map((o: any) => ({
208
+ id: o.id ?? '',
209
+ text: o.text ?? '',
210
+ percentage: o.percentage,
211
+ }))}
212
+ hasVoted={!!poll.myResponse}
213
+ selectedOptionId={poll.myResponse?.optionId}
214
+ totalVotes={poll.totalVotes}
215
+ primaryColor={primaryColor}
216
+ onPress={() => navigation.navigate('PollResponse', { pollId: poll.id })}
217
+ />
218
+ ))}
219
+ </View>
220
+ )}
221
+
222
+ {/* Feed */}
223
+ {freestyles.length > 0 && (
224
+ <View style={styles.section}>
225
+ <TitleSmall style={styles.sectionTitle}>Latest Freestyles</TitleSmall>
226
+ </View>
227
+ )}
228
+ </>
229
+ }
230
+ ListFooterComponent={
231
+ <View style={styles.feedSection}>
232
+ {injectSponsoredContent(freestyles, placements).map((item, index) => {
233
+ if (item.type === 'sponsored') {
234
+ const sp = item.data as SponsorshipPlacementData;
235
+ return (
236
+ <SponsoredContentCard
237
+ key={`sp-${sp.id}-${index}`}
238
+ placementId={sp.id}
239
+ brandName={sp.brandName}
240
+ brandImageUrl={sp.brandImageUrl}
241
+ headline={sp.headline}
242
+ bodyText={sp.bodyText}
243
+ ctaText={sp.ctaText}
244
+ ctaUrl={sp.ctaUrl}
245
+ onImpression={handleImpression}
246
+ onCtaPress={handleCtaPress}
247
+ />
248
+ );
249
+ }
250
+ const freestyle = item.data as any;
251
+ return (
252
+ <FreestyleCard
253
+ key={freestyle.id}
254
+ id={freestyle.id}
255
+ audioUrl={freestyle.audioUrl}
256
+ duration={freestyle.duration}
257
+ creatorName={freestyle.creator?.displayName}
258
+ creatorImageUrl={freestyle.creator?.imageUrl}
259
+ createdAt={freestyle.createdAt}
260
+ listenCount={freestyle.listenCount}
261
+ reactionCount={freestyle.reactionCount}
262
+ prompt={freestyle.prompt?.text}
263
+ />
264
+ );
265
+ })}
266
+
267
+ {connections.length === 0 && freestyles.length === 0 && (
268
+ <View style={styles.emptyState}>
269
+ <Text style={styles.emptyEmoji}>{'squad'}</Text>
270
+ <TitleSmall style={styles.emptyTitle}>Build Your Squad</TitleSmall>
271
+ <BodyRegular style={styles.emptySubtitle}>
272
+ Invite friends to start sharing freestyles, messages, and polls
273
+ </BodyRegular>
274
+ <Button
275
+ style={[styles.emptyButton, { backgroundColor: primaryColor }]}
276
+ onPress={() => navigation.navigate('Invite')}
277
+ >
278
+ <Text style={[styles.emptyButtonText, { color: theme.buttonText }]}>
279
+ Invite Friends
280
+ </Text>
281
+ </Button>
282
+ </View>
283
+ )}
284
+ </View>
285
+ }
286
+ refreshControl={
287
+ <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.white} />
288
+ }
289
+ />
290
+
291
+ {/* Interstitial sponsorship overlay — triggered by game moments */}
292
+ {interstitialPlacement && (
293
+ <InterstitialOverlay
294
+ visible={!!interstitialPlacement}
295
+ placementId={interstitialPlacement.id}
296
+ brandName={interstitialPlacement.brandName}
297
+ brandImageUrl={interstitialPlacement.brandImageUrl}
298
+ headline={interstitialPlacement.headline}
299
+ bodyText={interstitialPlacement.bodyText}
300
+ ctaText={interstitialPlacement.ctaText}
301
+ ctaUrl={interstitialPlacement.ctaUrl}
302
+ onImpression={(id, ms) => ImpressionTracker.shared.record(id, ms, 'interstitial', false)}
303
+ onCtaPress={(id) => ImpressionTracker.shared.record(id, 0, 'interstitial', true)}
304
+ onDismiss={() => setInterstitialPlacement(null)}
305
+ />
306
+ )}
307
+ </View>
308
+ );
309
+ }
310
+
311
+ const styles = StyleSheet.create({
312
+ container: { flex: 1 },
313
+ header: {
314
+ flexDirection: 'row',
315
+ justifyContent: 'space-between',
316
+ alignItems: 'center',
317
+ paddingHorizontal: 24,
318
+ paddingTop: 60,
319
+ paddingBottom: 16,
320
+ },
321
+ headerLeft: {},
322
+ headerRight: { flexDirection: 'row', gap: 12 },
323
+ greeting: { color: Colors.white },
324
+ squadCount: { color: Colors.gray6, marginTop: 2 },
325
+ actionIcon: {
326
+ width: 36, height: 36, borderRadius: 18, borderWidth: 1.5,
327
+ justifyContent: 'center', alignItems: 'center',
328
+ },
329
+ actionIconText: { fontSize: 20, fontWeight: '300', marginTop: -2 },
330
+ settingsIcon: {
331
+ width: 36, height: 36, borderRadius: 18,
332
+ backgroundColor: Colors.gray2, justifyContent: 'center', alignItems: 'center',
333
+ },
334
+ settingsIconText: { color: Colors.white, fontSize: 16, fontWeight: '700', marginTop: -4 },
335
+
336
+ // Squad circle
337
+ squadCircleSection: { marginBottom: 16 },
338
+ squadCircleList: { paddingHorizontal: 24, gap: 16 },
339
+ squadMember: { alignItems: 'center', width: 64 },
340
+ squadMemberName: { color: Colors.white, marginTop: 6, textAlign: 'center' },
341
+
342
+ // Actions
343
+ actionRow: { flexDirection: 'row', paddingHorizontal: 24, gap: 12, marginBottom: 24 },
344
+ actionButton: {
345
+ flex: 1, height: 72, borderRadius: 12,
346
+ justifyContent: 'center', alignItems: 'center', gap: 4,
347
+ },
348
+ actionButtonIcon: { fontSize: 20, fontWeight: '600' },
349
+ actionButtonLabel: { color: Colors.white },
350
+
351
+ // Sections
352
+ section: { paddingHorizontal: 24, marginBottom: 16 },
353
+ sectionTitle: { color: Colors.white, marginBottom: 12 },
354
+
355
+ // Feed
356
+ feedSection: { paddingHorizontal: 24, paddingBottom: 48 },
357
+
358
+ // Empty state
359
+ emptyState: { alignItems: 'center', paddingTop: 48, paddingHorizontal: 24 },
360
+ emptyEmoji: { fontSize: 48, marginBottom: 16, color: Colors.gray6 },
361
+ emptyTitle: { color: Colors.white, marginBottom: 8 },
362
+ emptySubtitle: { color: Colors.gray6, textAlign: 'center', marginBottom: 24 },
363
+ emptyButton: { paddingVertical: 14, paddingHorizontal: 32, borderRadius: 24 },
364
+ emptyButtonText: { fontSize: 15, fontWeight: '600' },
365
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Squad Circle — the circular arrangement of squad members on the home screen.
3
+ * Ported from squad-demo/src/screens/home/slivers/SquadCircle.tsx + CircleDisplay.tsx.
4
+ */
5
+ import React, { memo } from 'react';
6
+ import { View, StyleSheet, Pressable, Dimensions } from 'react-native';
7
+ import { useNavigation } from '@react-navigation/native';
8
+ import UserImage from '../../../components/ux/user-image/UserImage';
9
+ import { BodySmall } from '../../../components/ux/text/Typography';
10
+ import { Colors } from '../../../theme/ThemeContext';
11
+ import type { Connection } from '@squad-sports/core';
12
+
13
+ const { width: SCREEN_WIDTH } = Dimensions.get('window');
14
+ const CIRCLE_SIZE = SCREEN_WIDTH * 0.7;
15
+
16
+ interface SquadCircleProps {
17
+ connections: Connection[];
18
+ primaryColor?: string;
19
+ }
20
+
21
+ function SquadCircle({ connections, primaryColor = Colors.purple1 }: SquadCircleProps) {
22
+ const navigation = useNavigation();
23
+ const members = connections.slice(0, 8);
24
+ const count = members.length;
25
+
26
+ if (count === 0) return null;
27
+
28
+ const radius = CIRCLE_SIZE / 2 - 32;
29
+ const centerX = CIRCLE_SIZE / 2;
30
+ const centerY = CIRCLE_SIZE / 2;
31
+
32
+ return (
33
+ <View style={[styles.container, { width: CIRCLE_SIZE, height: CIRCLE_SIZE }]}>
34
+ {/* Circle outline */}
35
+ <View style={[styles.circleOutline, { borderColor: 'rgba(255,255,255,0.05)' }]} />
36
+
37
+ {members.map((conn, index) => {
38
+ const other = conn.recipient ?? conn.creator;
39
+ const angle = (2 * Math.PI * index) / count - Math.PI / 2;
40
+ const x = centerX + radius * Math.cos(angle) - 28;
41
+ const y = centerY + radius * Math.sin(angle) - 28;
42
+
43
+ return (
44
+ <Pressable
45
+ key={conn.id}
46
+ style={[styles.member, { left: x, top: y }]}
47
+ onPress={() => (navigation as any).navigate('Profile', { userId: other?.id ?? conn.id })}
48
+ accessibilityLabel={`${other?.displayName}'s profile`}
49
+ >
50
+ <UserImage
51
+ imageUrl={other?.imageUrl}
52
+ displayName={other?.displayName}
53
+ size={56}
54
+ borderColor={primaryColor}
55
+ />
56
+ <BodySmall style={styles.memberName} numberOfLines={1}>
57
+ {other?.displayName?.split(' ')[0] ?? ''}
58
+ </BodySmall>
59
+ </Pressable>
60
+ );
61
+ })}
62
+ </View>
63
+ );
64
+ }
65
+
66
+ export default memo(SquadCircle);
67
+
68
+ const styles = StyleSheet.create({
69
+ container: { alignSelf: 'center', position: 'relative', marginVertical: 16 },
70
+ circleOutline: {
71
+ ...StyleSheet.absoluteFillObject,
72
+ borderRadius: CIRCLE_SIZE / 2,
73
+ borderWidth: 1,
74
+ },
75
+ member: { position: 'absolute', alignItems: 'center', width: 64 },
76
+ memberName: { color: Colors.white, marginTop: 4, textAlign: 'center' },
77
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * QR code scanner for redeeming invite codes.
3
+ * Ported from squad-demo/src/screens/InvitationQrCode.tsx.
4
+ */
5
+ import React, { useCallback, useEffect, useState } from 'react';
6
+ import { View, Text, StyleSheet, Alert } from 'react-native';
7
+ import { useNavigation } from '@react-navigation/native';
8
+ import { useApiClient } from '../../SquadProvider';
9
+ import { useTheme, Colors } from '../../theme/ThemeContext';
10
+ import ScreenHeader from '../../components/ux/layout/ScreenHeader';
11
+ import Button from '../../components/ux/buttons/Button';
12
+ import { TitleMedium, BodyRegular, BodySmall } from '../../components/ux/text/Typography';
13
+
14
+ export function InvitationQrCodeScreen() {
15
+ const navigation = useNavigation();
16
+ const apiClient = useApiClient();
17
+ const { theme } = useTheme();
18
+ const [scanning, setScanning] = useState(false);
19
+ const [hasPermission, setHasPermission] = useState<boolean | null>(null);
20
+
21
+ useEffect(() => {
22
+ const requestPermission = async () => {
23
+ try {
24
+ const { Camera } = await import('expo-camera');
25
+ const { status } = await Camera.requestCameraPermissionsAsync();
26
+ setHasPermission(status === 'granted');
27
+ } catch {
28
+ setHasPermission(false);
29
+ }
30
+ };
31
+ requestPermission();
32
+ }, []);
33
+
34
+ const handleBarCodeScanned = useCallback(async (data: string) => {
35
+ if (scanning) return;
36
+ setScanning(true);
37
+
38
+ try {
39
+ // Extract invite code from URL or raw code
40
+ let code = data;
41
+ if (data.includes('/invite/')) {
42
+ code = data.split('/invite/').pop() ?? data;
43
+ }
44
+
45
+ const result = await apiClient.redeemInviteCode(code);
46
+ if (result) {
47
+ Alert.alert('Success', 'Invite code redeemed!', [
48
+ { text: 'OK', onPress: () => navigation.goBack() },
49
+ ]);
50
+ } else {
51
+ Alert.alert('Invalid Code', 'This invite code is not valid.');
52
+ setScanning(false);
53
+ }
54
+ } catch {
55
+ Alert.alert('Error', 'Failed to redeem invite code.');
56
+ setScanning(false);
57
+ }
58
+ }, [scanning, apiClient, navigation]);
59
+
60
+ return (
61
+ <View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
62
+ <ScreenHeader title="Scan QR Code" />
63
+
64
+ <View style={styles.content}>
65
+ {hasPermission === false ? (
66
+ <View style={styles.noPermission}>
67
+ <BodyRegular style={styles.noPermText}>
68
+ Camera permission is required to scan QR codes
69
+ </BodyRegular>
70
+ <Button
71
+ style={[styles.settingsBtn, { backgroundColor: theme.buttonColor }]}
72
+ onPress={() => {
73
+ const { Linking } = require('react-native');
74
+ Linking.openSettings();
75
+ }}
76
+ >
77
+ <Text style={[styles.settingsBtnText, { color: theme.buttonText }]}>
78
+ Open Settings
79
+ </Text>
80
+ </Button>
81
+ </View>
82
+ ) : (
83
+ <View style={styles.scanArea}>
84
+ <View style={styles.scanFrame}>
85
+ <View style={[styles.cornerTL, { borderColor: theme.buttonColor }]} />
86
+ <View style={[styles.cornerTR, { borderColor: theme.buttonColor }]} />
87
+ <View style={[styles.cornerBL, { borderColor: theme.buttonColor }]} />
88
+ <View style={[styles.cornerBR, { borderColor: theme.buttonColor }]} />
89
+ </View>
90
+ <BodySmall style={styles.scanHint}>
91
+ Point your camera at a Squad QR code
92
+ </BodySmall>
93
+ </View>
94
+ )}
95
+ </View>
96
+ </View>
97
+ );
98
+ }
99
+
100
+ const styles = StyleSheet.create({
101
+ container: { flex: 1 },
102
+ content: { flex: 1, justifyContent: 'center', alignItems: 'center' },
103
+ noPermission: { alignItems: 'center', padding: 32 },
104
+ noPermText: { color: Colors.gray6, textAlign: 'center', marginBottom: 16 },
105
+ settingsBtn: { paddingVertical: 12, paddingHorizontal: 24, borderRadius: 20 },
106
+ settingsBtnText: { fontWeight: '600' },
107
+ scanArea: { alignItems: 'center' },
108
+ scanFrame: { width: 250, height: 250, position: 'relative', marginBottom: 24 },
109
+ cornerTL: { position: 'absolute', top: 0, left: 0, width: 40, height: 40, borderTopWidth: 3, borderLeftWidth: 3 },
110
+ cornerTR: { position: 'absolute', top: 0, right: 0, width: 40, height: 40, borderTopWidth: 3, borderRightWidth: 3 },
111
+ cornerBL: { position: 'absolute', bottom: 0, left: 0, width: 40, height: 40, borderBottomWidth: 3, borderLeftWidth: 3 },
112
+ cornerBR: { position: 'absolute', bottom: 0, right: 0, width: 40, height: 40, borderBottomWidth: 3, borderRightWidth: 3 },
113
+ scanHint: { color: Colors.gray6 },
114
+ });