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