d3ro-voice/apps/mobile-rn/src/screens/TeamsScreen.tsx
2026-08-29 18:33:45 +09:00

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 },
})
}