322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
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<ParamListBase>
|
|
}
|
|
|
|
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<TeamSummary[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [error, setError] = useState<TeamServiceError['code'] | null>(null)
|
|
const [editorOpen, setEditorOpen] = useState(false)
|
|
const [name, setName] = useState('')
|
|
const [editorBusy, setEditorBusy] = useState(false)
|
|
const [editorError, setEditorError] = useState<TeamServiceError['code'] | null>(null)
|
|
|
|
const load = useCallback(async (pull = false): Promise<void> => {
|
|
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<void> => {
|
|
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 (
|
|
<View style={styles.container} testID="teams-screen">
|
|
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
|
|
<View style={styles.headerCopy}>
|
|
<ThemeText variant="title" accessibilityRole="header">
|
|
{t('mobile.teams.title')}
|
|
</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.teams.description')}
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeButton
|
|
label={t('mobile.teams.create')}
|
|
onPress={() => {
|
|
setName('')
|
|
setEditorError(null)
|
|
setEditorOpen(true)
|
|
}}
|
|
testID="teams-create"
|
|
/>
|
|
</View>
|
|
|
|
{error !== null && (
|
|
<ThemeCard style={styles.errorCard} accessibilityRole="alert" testID="teams-error">
|
|
<ThemeText color="danger">{teamErrorMessage(error, t)}</ThemeText>
|
|
<ThemeButton
|
|
label={t('common.retry')}
|
|
variant="secondary"
|
|
onPress={() => { void load(false) }}
|
|
testID="teams-retry"
|
|
/>
|
|
</ThemeCard>
|
|
)}
|
|
|
|
{loading && teams.length === 0 ? (
|
|
<View style={styles.center} testID="teams-loading">
|
|
<ActivityIndicator size="large" color={palette.accent.main} />
|
|
<ThemeText color="muted">{t('mobile.teams.loading')}</ThemeText>
|
|
</View>
|
|
) : (
|
|
<ScrollView
|
|
contentContainerStyle={teams.length === 0 ? styles.emptyContent : styles.content}
|
|
refreshControl={(
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={() => { void load(true) }}
|
|
colors={[palette.accent.main]}
|
|
tintColor={palette.accent.main}
|
|
/>
|
|
)}
|
|
>
|
|
{teams.length === 0 ? (
|
|
<ThemeCard style={styles.emptyCard} testID="teams-empty">
|
|
<ThemeText color="muted" style={styles.centeredText}>
|
|
{t('mobile.teams.empty')}
|
|
</ThemeText>
|
|
</ThemeCard>
|
|
) : teams.map((team) => (
|
|
<Pressable
|
|
key={team.id}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('mobile.teams.openLabel', { name: team.name })}
|
|
onPress={() => navigation.navigate('TeamDetail', { teamId: team.id })}
|
|
testID={`team-open-${team.id}`}
|
|
>
|
|
<ThemeCard style={styles.teamCard}>
|
|
<View style={styles.teamTop}>
|
|
<ThemeText variant="heading" style={styles.teamName} numberOfLines={2}>
|
|
{team.name}
|
|
</ThemeText>
|
|
<RoleBadge role={team.role} />
|
|
</View>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.teams.memberCount', { count: team.memberCount })}
|
|
</ThemeText>
|
|
</ThemeCard>
|
|
</Pressable>
|
|
))}
|
|
<View style={{ height: insets.bottom + 32 }} />
|
|
</ScrollView>
|
|
)}
|
|
|
|
<Modal
|
|
visible={editorOpen}
|
|
transparent
|
|
animationType="fade"
|
|
onRequestClose={() => { if (!editorBusy) setEditorOpen(false) }}
|
|
statusBarTranslucent
|
|
>
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
style={styles.modalBackdrop}
|
|
>
|
|
<ThemeCard style={styles.modalCard} testID="team-create-modal">
|
|
<ThemeText variant="title" accessibilityRole="header">
|
|
{t('mobile.teams.createTitle')}
|
|
</ThemeText>
|
|
<TextInput
|
|
accessibilityLabel={t('mobile.teams.nameLabel')}
|
|
value={name}
|
|
onChangeText={setName}
|
|
placeholder={t('mobile.teams.namePlaceholder')}
|
|
placeholderTextColor={palette.text.muted}
|
|
maxLength={81}
|
|
autoFocus
|
|
style={styles.input}
|
|
testID="team-name-input"
|
|
/>
|
|
{editorError !== null && (
|
|
<ThemeText color="danger" accessibilityRole="alert" testID="team-create-error">
|
|
{teamErrorMessage(editorError, t)}
|
|
</ThemeText>
|
|
)}
|
|
<View style={styles.modalActions}>
|
|
<ThemeButton
|
|
label={t('common.cancel')}
|
|
variant="secondary"
|
|
disabled={editorBusy}
|
|
onPress={() => setEditorOpen(false)}
|
|
style={styles.flex}
|
|
/>
|
|
<ThemeButton
|
|
label={editorBusy ? t('mobile.teams.creating') : t('mobile.teams.create')}
|
|
disabled={editorBusy || name.trim().length === 0}
|
|
onPress={() => { void submitCreate() }}
|
|
style={styles.flex}
|
|
testID="team-create-submit"
|
|
/>
|
|
</View>
|
|
</ThemeCard>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<View style={[stylesStatic.badge, { borderColor: color }]}>
|
|
<ThemeText variant="label" style={{ color }}>
|
|
{t(`mobile.teams.role.${role}`)}
|
|
</ThemeText>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function teamErrorCode(error: unknown): TeamServiceError['code'] {
|
|
return error instanceof TeamServiceError ? error.code : 'server'
|
|
}
|
|
|
|
type Translate = ReturnType<typeof useI18n>['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<typeof useMobilePreferences>['palette']
|
|
|
|
const stylesStatic = StyleSheet.create({
|
|
badge: {
|
|
minHeight: 28,
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 9,
|
|
borderWidth: 1,
|
|
borderRadius: 999,
|
|
},
|
|
})
|
|
|
|
function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|
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 },
|
|
})
|
|
}
|