@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,229 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
FlatList,
|
|
7
|
+
Pressable,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import { useRoute, useNavigation } from '@react-navigation/native';
|
|
10
|
+
import type { RouteProp } from '@react-navigation/native';
|
|
11
|
+
|
|
12
|
+
import type { RootStackParamList } from '../../navigation/SquadNavigator';
|
|
13
|
+
import { useApiClient } from '../../SquadProvider';
|
|
14
|
+
import { useTheme, Colors } from '../../theme/ThemeContext';
|
|
15
|
+
import ScreenHeader from '../../components/ux/layout/ScreenHeader';
|
|
16
|
+
import Button from '../../components/ux/buttons/Button';
|
|
17
|
+
import { TitleMedium, BodyRegular, BodySmall } from '../../components/ux/text/Typography';
|
|
18
|
+
|
|
19
|
+
type Route = RouteProp<RootStackParamList, 'PollResponse'>;
|
|
20
|
+
|
|
21
|
+
interface PollOption {
|
|
22
|
+
id: string;
|
|
23
|
+
text: string;
|
|
24
|
+
voteCount: number;
|
|
25
|
+
percentage: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PollResponseScreen() {
|
|
29
|
+
const route = useRoute<Route>();
|
|
30
|
+
const navigation = useNavigation();
|
|
31
|
+
const apiClient = useApiClient();
|
|
32
|
+
const { theme } = useTheme();
|
|
33
|
+
|
|
34
|
+
const { pollId } = route.params;
|
|
35
|
+
const [question, setQuestion] = useState('');
|
|
36
|
+
const [options, setOptions] = useState<PollOption[]>([]);
|
|
37
|
+
const [selectedOption, setSelectedOption] = useState<string | null>(null);
|
|
38
|
+
const [hasVoted, setHasVoted] = useState(false);
|
|
39
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const loadPoll = async () => {
|
|
43
|
+
try {
|
|
44
|
+
const polls = await apiClient.getActivePolls();
|
|
45
|
+
const poll = polls?.polls?.find((p: any) => p.id === pollId);
|
|
46
|
+
if (poll) {
|
|
47
|
+
setQuestion((poll as any).question ?? '');
|
|
48
|
+
setOptions(
|
|
49
|
+
(poll.options ?? []).map((o: any, i: number) => ({
|
|
50
|
+
id: o.id ?? String(i),
|
|
51
|
+
text: o.text ?? '',
|
|
52
|
+
voteCount: o.voteCount ?? 0,
|
|
53
|
+
percentage: o.percentage ?? 0,
|
|
54
|
+
})),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error('[PollResponse] Error loading poll:', err);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
loadPoll();
|
|
62
|
+
}, [pollId, apiClient]);
|
|
63
|
+
|
|
64
|
+
const handleVote = useCallback(async () => {
|
|
65
|
+
if (!selectedOption || isSubmitting) return;
|
|
66
|
+
|
|
67
|
+
setIsSubmitting(true);
|
|
68
|
+
try {
|
|
69
|
+
const { PollResponse } = await import('@squad-sports/core');
|
|
70
|
+
const response = new PollResponse({
|
|
71
|
+
optionId: selectedOption,
|
|
72
|
+
} as any);
|
|
73
|
+
await apiClient.createOrUpdatePollResponse(pollId, response);
|
|
74
|
+
setHasVoted(true);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error('[PollResponse] Error voting:', err);
|
|
77
|
+
} finally {
|
|
78
|
+
setIsSubmitting(false);
|
|
79
|
+
}
|
|
80
|
+
}, [selectedOption, pollId, apiClient, isSubmitting]);
|
|
81
|
+
|
|
82
|
+
const renderOption = useCallback(
|
|
83
|
+
({ item }: { item: PollOption }) => {
|
|
84
|
+
const isSelected = selectedOption === item.id;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Pressable
|
|
88
|
+
style={[
|
|
89
|
+
styles.option,
|
|
90
|
+
isSelected && [styles.optionSelected, { borderColor: theme.buttonColor }],
|
|
91
|
+
]}
|
|
92
|
+
onPress={() => !hasVoted && setSelectedOption(item.id)}
|
|
93
|
+
disabled={hasVoted}
|
|
94
|
+
>
|
|
95
|
+
<View style={styles.optionContent}>
|
|
96
|
+
<BodyRegular style={styles.optionText}>{item.text}</BodyRegular>
|
|
97
|
+
{hasVoted && (
|
|
98
|
+
<BodySmall style={styles.optionPercent}>
|
|
99
|
+
{Math.round(item.percentage)}%
|
|
100
|
+
</BodySmall>
|
|
101
|
+
)}
|
|
102
|
+
</View>
|
|
103
|
+
{hasVoted && (
|
|
104
|
+
<View style={styles.progressBarBg}>
|
|
105
|
+
<View
|
|
106
|
+
style={[
|
|
107
|
+
styles.progressBarFill,
|
|
108
|
+
{
|
|
109
|
+
width: `${item.percentage}%`,
|
|
110
|
+
backgroundColor: isSelected ? theme.buttonColor : Colors.gray5,
|
|
111
|
+
},
|
|
112
|
+
]}
|
|
113
|
+
/>
|
|
114
|
+
</View>
|
|
115
|
+
)}
|
|
116
|
+
</Pressable>
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
[selectedOption, hasVoted, theme.buttonColor],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
|
|
124
|
+
<ScreenHeader title="Poll" />
|
|
125
|
+
|
|
126
|
+
<View style={styles.content}>
|
|
127
|
+
<TitleMedium style={styles.question}>{question}</TitleMedium>
|
|
128
|
+
|
|
129
|
+
<FlatList
|
|
130
|
+
data={options}
|
|
131
|
+
keyExtractor={item => item.id}
|
|
132
|
+
renderItem={renderOption}
|
|
133
|
+
contentContainerStyle={styles.optionsList}
|
|
134
|
+
scrollEnabled={false}
|
|
135
|
+
/>
|
|
136
|
+
</View>
|
|
137
|
+
|
|
138
|
+
{!hasVoted && (
|
|
139
|
+
<View style={styles.footer}>
|
|
140
|
+
<Button
|
|
141
|
+
style={[
|
|
142
|
+
styles.voteButton,
|
|
143
|
+
{ backgroundColor: selectedOption ? theme.buttonColor : Colors.gray2 },
|
|
144
|
+
]}
|
|
145
|
+
onPress={handleVote}
|
|
146
|
+
disabled={!selectedOption || isSubmitting}
|
|
147
|
+
>
|
|
148
|
+
<Text
|
|
149
|
+
style={[
|
|
150
|
+
styles.voteButtonText,
|
|
151
|
+
{ color: selectedOption ? theme.buttonText : Colors.gray6 },
|
|
152
|
+
]}
|
|
153
|
+
>
|
|
154
|
+
{isSubmitting ? 'Submitting...' : 'Vote'}
|
|
155
|
+
</Text>
|
|
156
|
+
</Button>
|
|
157
|
+
</View>
|
|
158
|
+
)}
|
|
159
|
+
</View>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const styles = StyleSheet.create({
|
|
164
|
+
container: {
|
|
165
|
+
flex: 1,
|
|
166
|
+
},
|
|
167
|
+
content: {
|
|
168
|
+
flex: 1,
|
|
169
|
+
paddingHorizontal: 24,
|
|
170
|
+
paddingTop: 16,
|
|
171
|
+
},
|
|
172
|
+
question: {
|
|
173
|
+
color: Colors.white,
|
|
174
|
+
marginBottom: 24,
|
|
175
|
+
},
|
|
176
|
+
optionsList: {
|
|
177
|
+
gap: 8,
|
|
178
|
+
},
|
|
179
|
+
option: {
|
|
180
|
+
backgroundColor: Colors.gray2,
|
|
181
|
+
borderRadius: 12,
|
|
182
|
+
padding: 16,
|
|
183
|
+
borderWidth: 2,
|
|
184
|
+
borderColor: 'transparent',
|
|
185
|
+
overflow: 'hidden',
|
|
186
|
+
},
|
|
187
|
+
optionSelected: {
|
|
188
|
+
backgroundColor: 'rgba(110, 130, 231, 0.1)',
|
|
189
|
+
},
|
|
190
|
+
optionContent: {
|
|
191
|
+
flexDirection: 'row',
|
|
192
|
+
justifyContent: 'space-between',
|
|
193
|
+
alignItems: 'center',
|
|
194
|
+
},
|
|
195
|
+
optionText: {
|
|
196
|
+
color: Colors.white,
|
|
197
|
+
flex: 1,
|
|
198
|
+
},
|
|
199
|
+
optionPercent: {
|
|
200
|
+
color: Colors.gray6,
|
|
201
|
+
marginLeft: 8,
|
|
202
|
+
fontWeight: '600',
|
|
203
|
+
},
|
|
204
|
+
progressBarBg: {
|
|
205
|
+
height: 4,
|
|
206
|
+
backgroundColor: Colors.gray3,
|
|
207
|
+
borderRadius: 2,
|
|
208
|
+
marginTop: 8,
|
|
209
|
+
overflow: 'hidden',
|
|
210
|
+
},
|
|
211
|
+
progressBarFill: {
|
|
212
|
+
height: '100%',
|
|
213
|
+
borderRadius: 2,
|
|
214
|
+
},
|
|
215
|
+
footer: {
|
|
216
|
+
paddingHorizontal: 24,
|
|
217
|
+
paddingBottom: 32,
|
|
218
|
+
},
|
|
219
|
+
voteButton: {
|
|
220
|
+
height: 56,
|
|
221
|
+
borderRadius: 28,
|
|
222
|
+
justifyContent: 'center',
|
|
223
|
+
alignItems: 'center',
|
|
224
|
+
},
|
|
225
|
+
voteButtonText: {
|
|
226
|
+
fontSize: 16,
|
|
227
|
+
fontWeight: '600',
|
|
228
|
+
},
|
|
229
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poll summation/results screen.
|
|
3
|
+
* Ported from squad-demo/src/screens/PollSummation.tsx.
|
|
4
|
+
*/
|
|
5
|
+
import React, { useEffect, useState } from 'react';
|
|
6
|
+
import { View, StyleSheet, FlatList } from 'react-native';
|
|
7
|
+
import { useRoute } 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 { PollUserReaction, PollSummationSelector } from '../../components/poll/PollComponents';
|
|
12
|
+
import { TitleSmall, BodyRegular, BodySmall } from '../../components/ux/text/Typography';
|
|
13
|
+
|
|
14
|
+
export function PollSummationScreen() {
|
|
15
|
+
const route = useRoute<any>();
|
|
16
|
+
const apiClient = useApiClient();
|
|
17
|
+
const { theme } = useTheme();
|
|
18
|
+
|
|
19
|
+
const pollId = route.params?.pollId;
|
|
20
|
+
const [question, setQuestion] = useState('');
|
|
21
|
+
const [options, setOptions] = useState<any[]>([]);
|
|
22
|
+
const [responses, setResponses] = useState<any[]>([]);
|
|
23
|
+
const [totalVotes, setTotalVotes] = useState(0);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const load = async () => {
|
|
27
|
+
try {
|
|
28
|
+
const polls = await apiClient.getActivePolls();
|
|
29
|
+
const poll = polls?.polls?.find((p: any) => p.id === pollId);
|
|
30
|
+
if (poll) {
|
|
31
|
+
setQuestion((poll as any).question ?? '');
|
|
32
|
+
setOptions((poll as any).options ?? []);
|
|
33
|
+
setTotalVotes((poll as any).totalVotes ?? 0);
|
|
34
|
+
}
|
|
35
|
+
} catch {}
|
|
36
|
+
};
|
|
37
|
+
load();
|
|
38
|
+
}, [pollId, apiClient]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
|
|
42
|
+
<ScreenHeader title="Poll Results" />
|
|
43
|
+
|
|
44
|
+
<View style={styles.content}>
|
|
45
|
+
<TitleSmall style={styles.question}>{question}</TitleSmall>
|
|
46
|
+
<BodySmall style={styles.totalVotes}>{totalVotes} total votes</BodySmall>
|
|
47
|
+
|
|
48
|
+
{options.map((opt: any) => (
|
|
49
|
+
<View key={opt.id} style={styles.optionRow}>
|
|
50
|
+
<View style={styles.optionHeader}>
|
|
51
|
+
<BodyRegular style={styles.optionText}>{opt.text}</BodyRegular>
|
|
52
|
+
<BodySmall style={styles.optionPercent}>{Math.round(opt.percentage ?? 0)}%</BodySmall>
|
|
53
|
+
</View>
|
|
54
|
+
<View style={styles.progressBg}>
|
|
55
|
+
<View style={[styles.progressFill, {
|
|
56
|
+
width: `${opt.percentage ?? 0}%`,
|
|
57
|
+
backgroundColor: theme.buttonColor,
|
|
58
|
+
}]} />
|
|
59
|
+
</View>
|
|
60
|
+
</View>
|
|
61
|
+
))}
|
|
62
|
+
</View>
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const styles = StyleSheet.create({
|
|
68
|
+
container: { flex: 1 },
|
|
69
|
+
content: { padding: 24 },
|
|
70
|
+
question: { color: Colors.white, marginBottom: 8 },
|
|
71
|
+
totalVotes: { color: Colors.gray6, marginBottom: 24 },
|
|
72
|
+
optionRow: { marginBottom: 16 },
|
|
73
|
+
optionHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
|
74
|
+
optionText: { color: Colors.white },
|
|
75
|
+
optionPercent: { color: Colors.gray6, fontWeight: '600' },
|
|
76
|
+
progressBg: { height: 6, backgroundColor: Colors.gray3, borderRadius: 3, overflow: 'hidden' },
|
|
77
|
+
progressFill: { height: '100%', borderRadius: 3 },
|
|
78
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import React, { useEffect, useState, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
ScrollView,
|
|
7
|
+
RefreshControl,
|
|
8
|
+
Pressable,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { useRoute, useNavigation } from '@react-navigation/native';
|
|
11
|
+
import type { RouteProp } from '@react-navigation/native';
|
|
12
|
+
|
|
13
|
+
import type { RootStackParamList } from '../../navigation/SquadNavigator';
|
|
14
|
+
import { useApiClient } from '../../SquadProvider';
|
|
15
|
+
import { useTheme, Colors } from '../../theme/ThemeContext';
|
|
16
|
+
import ScreenHeader from '../../components/ux/layout/ScreenHeader';
|
|
17
|
+
import UserImage from '../../components/ux/user-image/UserImage';
|
|
18
|
+
import Button from '../../components/ux/buttons/Button';
|
|
19
|
+
import {
|
|
20
|
+
TitleMedium,
|
|
21
|
+
TitleSmall,
|
|
22
|
+
BodyRegular,
|
|
23
|
+
BodyMedium,
|
|
24
|
+
BodySmall,
|
|
25
|
+
} from '../../components/ux/text/Typography';
|
|
26
|
+
import type { User, UserActivity } from '@squad-sports/core';
|
|
27
|
+
|
|
28
|
+
type Route = RouteProp<RootStackParamList, 'Profile'>;
|
|
29
|
+
|
|
30
|
+
export function ProfileScreen() {
|
|
31
|
+
const route = useRoute<Route>();
|
|
32
|
+
const navigation = useNavigation();
|
|
33
|
+
const apiClient = useApiClient();
|
|
34
|
+
const { theme } = useTheme();
|
|
35
|
+
|
|
36
|
+
const { userId } = route.params;
|
|
37
|
+
const [user, setUser] = useState<User | null>(null);
|
|
38
|
+
const [activity, setActivity] = useState<UserActivity | null>(null);
|
|
39
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
40
|
+
const [isOwnProfile, setIsOwnProfile] = useState(false);
|
|
41
|
+
|
|
42
|
+
const loadProfile = useCallback(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const [userData, activityData, loggedInUser] = await Promise.all([
|
|
45
|
+
apiClient.getUser(userId),
|
|
46
|
+
apiClient.getUserActivity(userId),
|
|
47
|
+
apiClient.getLoggedInUser(),
|
|
48
|
+
]);
|
|
49
|
+
setUser(userData);
|
|
50
|
+
setActivity(activityData);
|
|
51
|
+
setIsOwnProfile(loggedInUser?.id === userId);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('[Profile] Error loading profile:', err);
|
|
54
|
+
}
|
|
55
|
+
}, [userId, apiClient]);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
loadProfile();
|
|
59
|
+
}, [loadProfile]);
|
|
60
|
+
|
|
61
|
+
const onRefresh = useCallback(async () => {
|
|
62
|
+
setRefreshing(true);
|
|
63
|
+
await loadProfile();
|
|
64
|
+
setRefreshing(false);
|
|
65
|
+
}, [loadProfile]);
|
|
66
|
+
|
|
67
|
+
if (!user) return null;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
|
|
71
|
+
<ScreenHeader
|
|
72
|
+
title={isOwnProfile ? 'My Profile' : user.displayName ?? ''}
|
|
73
|
+
right={
|
|
74
|
+
isOwnProfile ? (
|
|
75
|
+
<Pressable onPress={() => (navigation as any).navigate('Settings')}>
|
|
76
|
+
<Text style={styles.settingsIcon}>{'...'}</Text>
|
|
77
|
+
</Pressable>
|
|
78
|
+
) : undefined
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
|
|
82
|
+
<ScrollView
|
|
83
|
+
contentContainerStyle={styles.scrollContent}
|
|
84
|
+
refreshControl={
|
|
85
|
+
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.white} />
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
<View style={styles.profileHeader}>
|
|
89
|
+
<UserImage
|
|
90
|
+
imageUrl={user.imageUrl}
|
|
91
|
+
displayName={user.displayName}
|
|
92
|
+
size={96}
|
|
93
|
+
/>
|
|
94
|
+
<TitleMedium style={styles.displayName}>
|
|
95
|
+
{user.displayName}
|
|
96
|
+
</TitleMedium>
|
|
97
|
+
{user.community && (
|
|
98
|
+
<BodyMedium style={styles.community}>{user.community}</BodyMedium>
|
|
99
|
+
)}
|
|
100
|
+
</View>
|
|
101
|
+
|
|
102
|
+
{activity && (
|
|
103
|
+
<View style={styles.statsRow}>
|
|
104
|
+
<View style={styles.stat}>
|
|
105
|
+
<TitleSmall style={styles.statValue}>
|
|
106
|
+
{(activity as any).freestyleCount ?? activity.freestylesSent ?? 0}
|
|
107
|
+
</TitleSmall>
|
|
108
|
+
<BodySmall style={styles.statLabel}>Freestyles</BodySmall>
|
|
109
|
+
</View>
|
|
110
|
+
<View style={styles.statDivider} />
|
|
111
|
+
<View style={styles.stat}>
|
|
112
|
+
<TitleSmall style={styles.statValue}>
|
|
113
|
+
{(activity as any).messageCount ?? activity.messagesSent ?? 0}
|
|
114
|
+
</TitleSmall>
|
|
115
|
+
<BodySmall style={styles.statLabel}>Messages</BodySmall>
|
|
116
|
+
</View>
|
|
117
|
+
<View style={styles.statDivider} />
|
|
118
|
+
<View style={styles.stat}>
|
|
119
|
+
<TitleSmall style={styles.statValue}>
|
|
120
|
+
{(activity as any).callCount ?? activity.callsMade ?? 0}
|
|
121
|
+
</TitleSmall>
|
|
122
|
+
<BodySmall style={styles.statLabel}>Calls</BodySmall>
|
|
123
|
+
</View>
|
|
124
|
+
</View>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{!isOwnProfile && (
|
|
128
|
+
<View style={styles.actions}>
|
|
129
|
+
<Button
|
|
130
|
+
style={[styles.actionButton, { backgroundColor: theme.buttonColor }]}
|
|
131
|
+
onPress={() =>
|
|
132
|
+
(navigation as any).navigate('Messaging', { connectionId: userId })
|
|
133
|
+
}
|
|
134
|
+
>
|
|
135
|
+
<Text style={[styles.actionButtonText, { color: theme.buttonText }]}>
|
|
136
|
+
Message
|
|
137
|
+
</Text>
|
|
138
|
+
</Button>
|
|
139
|
+
<Button
|
|
140
|
+
style={styles.actionButtonOutline}
|
|
141
|
+
onPress={() =>
|
|
142
|
+
(navigation as any).navigate('AddCallTitle', { connectionId: userId })
|
|
143
|
+
}
|
|
144
|
+
>
|
|
145
|
+
<Text style={styles.actionButtonOutlineText}>Call</Text>
|
|
146
|
+
</Button>
|
|
147
|
+
</View>
|
|
148
|
+
)}
|
|
149
|
+
</ScrollView>
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const styles = StyleSheet.create({
|
|
155
|
+
container: {
|
|
156
|
+
flex: 1,
|
|
157
|
+
},
|
|
158
|
+
scrollContent: {
|
|
159
|
+
paddingHorizontal: 24,
|
|
160
|
+
paddingBottom: 48,
|
|
161
|
+
},
|
|
162
|
+
profileHeader: {
|
|
163
|
+
alignItems: 'center',
|
|
164
|
+
paddingTop: 16,
|
|
165
|
+
paddingBottom: 24,
|
|
166
|
+
},
|
|
167
|
+
displayName: {
|
|
168
|
+
color: Colors.white,
|
|
169
|
+
marginTop: 12,
|
|
170
|
+
},
|
|
171
|
+
community: {
|
|
172
|
+
color: Colors.gray6,
|
|
173
|
+
marginTop: 4,
|
|
174
|
+
},
|
|
175
|
+
statsRow: {
|
|
176
|
+
flexDirection: 'row',
|
|
177
|
+
justifyContent: 'center',
|
|
178
|
+
alignItems: 'center',
|
|
179
|
+
paddingVertical: 20,
|
|
180
|
+
backgroundColor: Colors.gray2,
|
|
181
|
+
borderRadius: 12,
|
|
182
|
+
marginBottom: 24,
|
|
183
|
+
},
|
|
184
|
+
stat: {
|
|
185
|
+
flex: 1,
|
|
186
|
+
alignItems: 'center',
|
|
187
|
+
},
|
|
188
|
+
statValue: {
|
|
189
|
+
color: Colors.white,
|
|
190
|
+
},
|
|
191
|
+
statLabel: {
|
|
192
|
+
color: Colors.gray6,
|
|
193
|
+
marginTop: 4,
|
|
194
|
+
},
|
|
195
|
+
statDivider: {
|
|
196
|
+
width: 1,
|
|
197
|
+
height: 32,
|
|
198
|
+
backgroundColor: Colors.gray5,
|
|
199
|
+
},
|
|
200
|
+
actions: {
|
|
201
|
+
flexDirection: 'row',
|
|
202
|
+
gap: 12,
|
|
203
|
+
},
|
|
204
|
+
actionButton: {
|
|
205
|
+
flex: 1,
|
|
206
|
+
height: 48,
|
|
207
|
+
borderRadius: 24,
|
|
208
|
+
justifyContent: 'center',
|
|
209
|
+
alignItems: 'center',
|
|
210
|
+
},
|
|
211
|
+
actionButtonText: {
|
|
212
|
+
fontSize: 15,
|
|
213
|
+
fontWeight: '600',
|
|
214
|
+
},
|
|
215
|
+
actionButtonOutline: {
|
|
216
|
+
flex: 1,
|
|
217
|
+
height: 48,
|
|
218
|
+
borderRadius: 24,
|
|
219
|
+
justifyContent: 'center',
|
|
220
|
+
alignItems: 'center',
|
|
221
|
+
borderWidth: 1,
|
|
222
|
+
borderColor: Colors.gray5,
|
|
223
|
+
},
|
|
224
|
+
actionButtonOutlineText: {
|
|
225
|
+
color: Colors.white,
|
|
226
|
+
fontSize: 15,
|
|
227
|
+
fontWeight: '600',
|
|
228
|
+
},
|
|
229
|
+
settingsIcon: {
|
|
230
|
+
color: Colors.white,
|
|
231
|
+
fontSize: 20,
|
|
232
|
+
fontWeight: '700',
|
|
233
|
+
},
|
|
234
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
FlatList,
|
|
7
|
+
Alert,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
|
|
10
|
+
import { useApiClient } from '../../SquadProvider';
|
|
11
|
+
import { useTheme, Colors } from '../../theme/ThemeContext';
|
|
12
|
+
import ScreenHeader from '../../components/ux/layout/ScreenHeader';
|
|
13
|
+
import Button from '../../components/ux/buttons/Button';
|
|
14
|
+
import UserImage from '../../components/ux/user-image/UserImage';
|
|
15
|
+
import { BodyRegular, BodyMedium } from '../../components/ux/text/Typography';
|
|
16
|
+
import type { User } from '@squad-sports/core';
|
|
17
|
+
|
|
18
|
+
interface BlockedUserItem {
|
|
19
|
+
id: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
imageUrl?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function BlockedUsersScreen() {
|
|
25
|
+
const apiClient = useApiClient();
|
|
26
|
+
const { theme } = useTheme();
|
|
27
|
+
const [blockedUsers, setBlockedUsers] = useState<BlockedUserItem[]>([]);
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const loadBlocked = async () => {
|
|
32
|
+
try {
|
|
33
|
+
// Get connections and filter for blocked status
|
|
34
|
+
const squad = await apiClient.getUserConnections(true);
|
|
35
|
+
if (squad?.connections) {
|
|
36
|
+
const blocked = squad.connections
|
|
37
|
+
.filter((c: any) => c.status === 6) // CONNECTION_STATUS_BLOCKED
|
|
38
|
+
.map((c: any) => {
|
|
39
|
+
const other = c.recipient ?? c.creator;
|
|
40
|
+
return {
|
|
41
|
+
id: other?.id ?? c.id,
|
|
42
|
+
displayName: other?.displayName ?? 'Unknown',
|
|
43
|
+
imageUrl: other?.imageUrl,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
setBlockedUsers(blocked);
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error('[BlockedUsers] Error loading:', err);
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
loadBlocked();
|
|
55
|
+
}, [apiClient]);
|
|
56
|
+
|
|
57
|
+
const handleUnblock = useCallback(
|
|
58
|
+
async (userId: string, displayName: string) => {
|
|
59
|
+
Alert.alert(
|
|
60
|
+
'Unblock User',
|
|
61
|
+
`Are you sure you want to unblock ${displayName}?`,
|
|
62
|
+
[
|
|
63
|
+
{ text: 'Cancel', style: 'cancel' },
|
|
64
|
+
{
|
|
65
|
+
text: 'Unblock',
|
|
66
|
+
onPress: async () => {
|
|
67
|
+
try {
|
|
68
|
+
const { User } = await import('@squad-sports/core');
|
|
69
|
+
const user = new User({ id: userId });
|
|
70
|
+
await apiClient.unblockUser(user);
|
|
71
|
+
setBlockedUsers(prev => prev.filter(u => u.id !== userId));
|
|
72
|
+
} catch {
|
|
73
|
+
Alert.alert('Error', 'Failed to unblock user.');
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
[apiClient],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
|
|
85
|
+
<ScreenHeader title="Blocked Users" />
|
|
86
|
+
|
|
87
|
+
{blockedUsers.length === 0 && !loading ? (
|
|
88
|
+
<View style={styles.emptyState}>
|
|
89
|
+
<BodyRegular style={styles.emptyText}>
|
|
90
|
+
No blocked users
|
|
91
|
+
</BodyRegular>
|
|
92
|
+
<BodyMedium style={styles.emptySubtext}>
|
|
93
|
+
Users you block will appear here
|
|
94
|
+
</BodyMedium>
|
|
95
|
+
</View>
|
|
96
|
+
) : (
|
|
97
|
+
<FlatList
|
|
98
|
+
data={blockedUsers}
|
|
99
|
+
keyExtractor={item => item.id}
|
|
100
|
+
contentContainerStyle={styles.list}
|
|
101
|
+
renderItem={({ item }) => (
|
|
102
|
+
<View style={styles.row}>
|
|
103
|
+
<UserImage
|
|
104
|
+
imageUrl={item.imageUrl}
|
|
105
|
+
displayName={item.displayName}
|
|
106
|
+
size={44}
|
|
107
|
+
/>
|
|
108
|
+
<BodyRegular style={styles.name}>{item.displayName}</BodyRegular>
|
|
109
|
+
<Button
|
|
110
|
+
style={styles.unblockButton}
|
|
111
|
+
onPress={() => handleUnblock(item.id, item.displayName)}
|
|
112
|
+
>
|
|
113
|
+
<Text style={styles.unblockText}>Unblock</Text>
|
|
114
|
+
</Button>
|
|
115
|
+
</View>
|
|
116
|
+
)}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
</View>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const styles = StyleSheet.create({
|
|
124
|
+
container: { flex: 1 },
|
|
125
|
+
list: { paddingHorizontal: 24 },
|
|
126
|
+
row: {
|
|
127
|
+
flexDirection: 'row', alignItems: 'center', paddingVertical: 12,
|
|
128
|
+
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.gray3,
|
|
129
|
+
},
|
|
130
|
+
name: { color: Colors.white, flex: 1, marginLeft: 12 },
|
|
131
|
+
unblockButton: {
|
|
132
|
+
paddingVertical: 6, paddingHorizontal: 16,
|
|
133
|
+
borderRadius: 16, borderWidth: 1, borderColor: Colors.gray5,
|
|
134
|
+
},
|
|
135
|
+
unblockText: { color: Colors.white, fontSize: 13 },
|
|
136
|
+
emptyState: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
|
137
|
+
emptyText: { color: Colors.white, marginBottom: 4 },
|
|
138
|
+
emptySubtext: { color: Colors.gray6 },
|
|
139
|
+
});
|