import { useCallback, useMemo, useRef, useState } from 'react' import { ActivityIndicator, KeyboardAvoidingView, Modal, Platform, Pressable, RefreshControl, ScrollView, StyleSheet, TextInput, View, } from 'react-native' import type { NavigationProp, ParamListBase } from '@react-navigation/native' import { useFocusEffect } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useI18n } from '@d3ro/i18n' import { useAuth } from '../lib/auth-context' import { useMobilePreferences } from '../lib/preferences-context' import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components' import { createTeam, listTeams, TeamServiceError, type TeamSummary, } from '../features/teams/team-service' interface TeamsScreenProps { navigation: NavigationProp } export default function TeamsScreen({ navigation }: TeamsScreenProps): React.ReactElement { const insets = useSafeAreaInsets() const { t } = useI18n() const { user } = useAuth() const { palette } = useMobilePreferences() const styles = useMemo(() => createStyles(palette), [palette]) const loadGeneration = useRef(0) const [teams, setTeams] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) const [editorOpen, setEditorOpen] = useState(false) const [name, setName] = useState('') const [editorBusy, setEditorBusy] = useState(false) const [editorError, setEditorError] = useState(null) const load = useCallback(async (pull = false): Promise => { const generation = ++loadGeneration.current if (user === null) { setLoading(false) setRefreshing(false) setError('auth') return } if (pull) setRefreshing(true) else setLoading(true) setError(null) try { const rows = await listTeams(user.id) if (generation === loadGeneration.current) setTeams(rows) } catch (requestError) { if (generation === loadGeneration.current) setError(teamErrorCode(requestError)) } finally { if (generation === loadGeneration.current) { setLoading(false) setRefreshing(false) } } }, [user]) useFocusEffect(useCallback(() => { void load(false) return () => { loadGeneration.current += 1 } }, [load])) const submitCreate = async (): Promise => { if (user === null || editorBusy) return setEditorBusy(true) setEditorError(null) try { const team = await createTeam(user.id, name) setTeams((current) => [team, ...current.filter((row) => row.id !== team.id)]) setName('') setEditorOpen(false) navigation.navigate('TeamDetail', { teamId: team.id }) } catch (requestError) { setEditorError(teamErrorCode(requestError)) } finally { setEditorBusy(false) } } return ( {t('mobile.teams.title')} {t('mobile.teams.description')} { setName('') setEditorError(null) setEditorOpen(true) }} testID="teams-create" /> {error !== null && ( {teamErrorMessage(error, t)} { void load(false) }} testID="teams-retry" /> )} {loading && teams.length === 0 ? ( {t('mobile.teams.loading')} ) : ( { void load(true) }} colors={[palette.accent.main]} tintColor={palette.accent.main} /> )} > {teams.length === 0 ? ( {t('mobile.teams.empty')} ) : teams.map((team) => ( navigation.navigate('TeamDetail', { teamId: team.id })} testID={`team-open-${team.id}`} > {team.name} {t('mobile.teams.memberCount', { count: team.memberCount })} ))} )} { if (!editorBusy) setEditorOpen(false) }} statusBarTranslucent > {t('mobile.teams.createTitle')} {editorError !== null && ( {teamErrorMessage(editorError, t)} )} setEditorOpen(false)} style={styles.flex} /> { void submitCreate() }} style={styles.flex} testID="team-create-submit" /> ) } function RoleBadge({ role }: { role: TeamSummary['role'] }): React.ReactElement { const { t } = useI18n() const { palette } = useMobilePreferences() const color = role === 'owner' ? palette.tag.purple : role === 'admin' ? palette.tag.orange : palette.tag.green return ( {t(`mobile.teams.role.${role}`)} ) } export function teamErrorCode(error: unknown): TeamServiceError['code'] { return error instanceof TeamServiceError ? error.code : 'server' } type Translate = ReturnType['t'] export function teamErrorMessage(code: TeamServiceError['code'], t: Translate): string { if (code === 'auth') return t('mobile.teams.error.auth') if (code === 'conflict') return t('mobile.teams.error.conflict') if (code === 'duplicate') return t('mobile.teams.error.duplicate') if (code === 'email-mismatch') return t('mobile.teams.error.emailMismatch') if (code === 'expired') return t('mobile.teams.error.expired') if (code === 'forbidden') return t('mobile.teams.error.forbidden') if (code === 'invalid-token') return t('mobile.teams.error.invalidToken') if (code === 'network') return t('mobile.teams.error.network') if (code === 'not-found') return t('mobile.teams.error.notFound') if (code === 'rate-limited') return t('mobile.teams.error.rateLimited') if (code === 'server-contract') return t('mobile.teams.error.serverContract') if (code === 'validation') return t('mobile.teams.error.validation') return t('mobile.teams.error.server') } type Palette = ReturnType['palette'] const stylesStatic = StyleSheet.create({ badge: { minHeight: 28, justifyContent: 'center', paddingHorizontal: 9, borderWidth: 1, borderRadius: 999, }, }) function createStyles(palette: Palette): ReturnType { return StyleSheet.create({ container: { flex: 1, backgroundColor: palette.bg.app }, header: { minHeight: 110, paddingHorizontal: 20, paddingBottom: 14, flexDirection: 'row', alignItems: 'center', gap: 14, }, headerCopy: { flex: 1, gap: 5 }, content: { paddingHorizontal: 20, paddingBottom: 32, gap: 12 }, emptyContent: { flexGrow: 1, justifyContent: 'center', padding: 20 }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }, centeredText: { textAlign: 'center' }, emptyCard: { paddingVertical: 40 }, errorCard: { marginHorizontal: 20, marginBottom: 12, gap: 12 }, teamCard: { gap: 10 }, teamTop: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, teamName: { flex: 1 }, modalBackdrop: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: 'rgba(0,0,0,0.62)', }, modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 }, input: { minHeight: 52, paddingHorizontal: 14, borderWidth: 1, borderColor: palette.border.strong, borderRadius: 10, backgroundColor: palette.bg.inset, color: palette.text.primary, fontSize: 15, }, modalActions: { flexDirection: 'row', gap: 10 }, flex: { flex: 1 }, }) }