d3ro-voice/apps/mobile-rn/src/screens/TeamsScreen.tsx
Yun Chan 94d8bb8ebe feat(mobile): keep team, template, and report flows in sync with the server
Team, meeting, memo, template, command, and dictionary screens drifted from
the server contract, and report submission could hang instead of confirming
to the user. The screens now use the server responses directly.

The retired Expo shell is removed; the React Native app is the mobile client.
Gradle-generated vector-icon drawables are ignored rather than committed.
2026-09-16 23:25:51 +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: palette.scrim,
},
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 },
})
}