This commit is contained in:
2026-08-26 23:01:07 +03:00
commit 015b528127
67 changed files with 20201 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import { Stack } from 'expo-router';
import { colors } from '@/theme';
export default function AuthLayout() {
return (
<Stack
screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.surface } }}
/>
);
}
+62
View File
@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import { useRouter } from 'expo-router';
import { api } from '@/api';
import { useT } from '@/i18n';
import { AuthShell } from '@/components/AuthShell';
import { Button, Field, TextField } from '@/components/ui';
import { spacing } from '@/theme';
export default function ForgotPasswordScreen() {
const router = useRouter();
const t = useT();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit() {
const mail = email.trim();
if (!mail || !mail.includes('@')) return setError(t('register.badEmail'));
setError(null);
setLoading(true);
try {
await api.auth.forgotPassword(mail);
} catch (e: any) {
setError(e?.message ?? t('common.tryAgain'));
setLoading(false);
return;
}
setLoading(false);
// The endpoint succeeds even for unknown addresses, and the screen must
// not reveal whether the account exists.
router.replace(`/reset?email=${encodeURIComponent(mail)}`);
}
return (
<AuthShell title={t('forgot.title')} subtitle={t('forgot.subtitle')} back error={error}>
<Field label={t('login.email')}>
<TextField
icon="mail-outline"
value={email}
onChangeText={setEmail}
placeholder={t('login.emailPlaceholder')}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
textContentType="emailAddress"
autoFocus
/>
</Field>
<Button
title={t('forgot.submit')}
size="lg"
block
loading={loading}
onPress={onSubmit}
style={{ marginTop: spacing[2] }}
/>
</AuthShell>
);
}
+191
View File
@@ -0,0 +1,191 @@
import React, { useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { Button, Field, TextField } from '@/components/ui';
import { useRouter } from 'expo-router';
import { useAuth } from '@/auth/AuthContext';
import { USE_MOCK } from '@/api';
import { useT } from '@/i18n';
import { colors, fontSize, radii, spacing } from '@/theme';
export default function LoginScreen() {
const insets = useSafeAreaInsets();
const { signIn } = useAuth();
const router = useRouter();
const t = useT();
const [email, setEmail] = useState(USE_MOCK ? 'j.smith@casadoc.app' : '');
const [password, setPassword] = useState(USE_MOCK ? 'demo' : '');
const [showPass, setShowPass] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSignIn() {
if (!email.trim() || !password) {
setError(t('login.missingFields'));
return;
}
setError(null);
setLoading(true);
try {
await signIn(email.trim(), password);
} catch (e: any) {
// Unverified is not a wrong password: send them to the code screen.
if (e?.code === 'email_not_verified') {
router.push(`/verify?email=${encodeURIComponent(email.trim())}`);
return;
}
// The backend's message is English-only; ours says the same, localized.
setError(e?.status === 401 || e?.status === 422 ? t('login.failed') : (e?.message ?? t('login.failed')));
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.surface }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={[
styles.container,
{ paddingTop: insets.top + spacing[8], paddingBottom: insets.bottom + spacing[6] },
]}
keyboardShouldPersistTaps="handled"
>
<View style={styles.brandWrap}>
<View style={styles.brandMark}>
<Ionicons name="home" size={30} color={colors.onDark} />
</View>
<Text style={styles.brand}>
Casa<Text style={{ color: colors.teal500 }}>Doc</Text>
</Text>
<Text style={styles.tagline}>
{t('login.tagline')}
</Text>
</View>
<View style={{ marginTop: spacing[9] }}>
<Field label={t('login.email')}>
<TextField
icon="mail-outline"
value={email}
onChangeText={setEmail}
placeholder={t('login.emailPlaceholder')}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
</Field>
<Field label={t('login.password')}>
<TextField
icon="lock-closed-outline"
value={password}
onChangeText={setPassword}
placeholder={t('login.passwordPlaceholder')}
secureTextEntry={!showPass}
autoCapitalize="none"
/>
<Pressable onPress={() => setShowPass((s) => !s)} style={styles.showPass} hitSlop={8}>
<Text style={styles.showPassText}>{showPass ? t('login.hide') : t('login.show')}</Text>
</Pressable>
</Field>
{error ? (
<View style={styles.errorBox}>
<Ionicons name="alert-circle-outline" size={18} color={colors.dangerText} />
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
<Button
title={t('login.signIn')}
iconRight="arrow-forward"
size="lg"
block
loading={loading}
onPress={onSignIn}
style={{ marginTop: spacing[3] }}
/>
<Pressable onPress={() => router.push('/forgot')} style={styles.forgot}>
<Text style={styles.linkText}>{t('login.forgot')}</Text>
</Pressable>
<Pressable onPress={() => router.push('/register')} style={styles.registerRow}>
<Text style={styles.muted}>
{t('login.noAccount')} <Text style={styles.linkText}>{t('register.submit')}</Text>
</Text>
</Pressable>
{USE_MOCK ? (
<Text style={styles.demoHint}>{t('login.demoHint')}</Text>
) : null}
</View>
<View style={styles.trust}>
<Ionicons name="shield-checkmark-outline" size={16} color={colors.slate500} />
<Text style={styles.trustText}>{t('login.trust')}</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { paddingHorizontal: spacing[5], flexGrow: 1 },
brandWrap: { alignItems: 'center' },
brandMark: {
width: 68,
height: 68,
borderRadius: 20,
backgroundColor: colors.ink900,
alignItems: 'center',
justifyContent: 'center',
},
brand: { fontSize: fontSize['3xl'], fontWeight: '700', color: colors.ink, marginTop: spacing[4] },
tagline: {
fontSize: fontSize.sm,
color: colors.textMuted,
textAlign: 'center',
marginTop: 8,
maxWidth: 260,
},
showPass: { position: 'absolute', right: 14, top: 40 },
showPassText: { color: colors.teal700, fontWeight: '600', fontSize: fontSize.sm },
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.dangerBg,
borderRadius: radii.md,
padding: spacing[3],
marginBottom: spacing[3],
},
errorText: { color: colors.dangerText, fontSize: fontSize.sm, flex: 1 },
forgot: { alignSelf: 'center', marginTop: spacing[4] },
registerRow: { alignItems: 'center', marginTop: spacing[5] },
linkText: { color: colors.teal700, fontWeight: '600', fontSize: fontSize.sm },
muted: { fontSize: fontSize.sm, color: colors.textMuted },
demoHint: { textAlign: 'center', color: colors.textMuted2, fontSize: fontSize.xs, marginTop: spacing[4] },
trust: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 7,
marginTop: 'auto',
paddingTop: spacing[7],
},
trustText: { fontSize: fontSize.sm, color: colors.slate500 },
});
+120
View File
@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { api } from '@/api';
import { useT } from '@/i18n';
import { AuthShell } from '@/components/AuthShell';
import { Button, Field, TextField } from '@/components/ui';
import { colors, fontSize, spacing } from '@/theme';
/** Enforced by the backend; keep the two in step. */
const MIN_PASSWORD = 8;
export default function RegisterScreen() {
const router = useRouter();
const t = useT();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPass, setShowPass] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit() {
const mail = email.trim();
if (!mail || !mail.includes('@')) return setError(t('register.badEmail'));
if (password.length < MIN_PASSWORD) return setError(t('register.shortPassword', { count: MIN_PASSWORD }));
setError(null);
setLoading(true);
try {
await api.auth.register({
email: mail,
password,
firstName: firstName.trim() || undefined,
lastName: lastName.trim() || undefined,
});
// The account exists but has no session until the code is verified.
router.replace(`/verify?email=${encodeURIComponent(mail)}`);
} catch (e: any) {
setError(e?.fields?.email?.[0] ? t('register.emailTaken') : (e?.message ?? t('common.tryAgain')));
} finally {
setLoading(false);
}
}
return (
<AuthShell title={t('register.title')} subtitle={t('register.subtitle')} back error={error}>
<View style={{ flexDirection: 'row', gap: spacing[3] }}>
<Field label={t('register.firstName')} optional style={{ flex: 1 }}>
<TextField
value={firstName}
onChangeText={setFirstName}
autoCapitalize="words"
textContentType="givenName"
/>
</Field>
<Field label={t('register.lastName')} optional style={{ flex: 1 }}>
<TextField
value={lastName}
onChangeText={setLastName}
autoCapitalize="words"
textContentType="familyName"
/>
</Field>
</View>
<Field label={t('login.email')}>
<TextField
icon="mail-outline"
value={email}
onChangeText={setEmail}
placeholder={t('login.emailPlaceholder')}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
textContentType="emailAddress"
/>
</Field>
<Field label={t('login.password')}>
<TextField
icon="lock-closed-outline"
value={password}
onChangeText={setPassword}
placeholder={t('register.passwordHint', { count: MIN_PASSWORD })}
secureTextEntry={!showPass}
autoCapitalize="none"
textContentType="newPassword"
/>
<Pressable onPress={() => setShowPass((v) => !v)} style={styles.showPass} hitSlop={8}>
<Text style={styles.link}>{showPass ? t('login.hide') : t('login.show')}</Text>
</Pressable>
</Field>
<Button
title={t('register.submit')}
size="lg"
block
loading={loading}
onPress={onSubmit}
style={{ marginTop: spacing[2] }}
/>
<Pressable onPress={() => router.back()} style={styles.footer}>
<Text style={styles.footerText}>
{t('register.haveAccount')} <Text style={styles.link}>{t('login.signIn')}</Text>
</Text>
</Pressable>
</AuthShell>
);
}
const styles = StyleSheet.create({
showPass: { position: 'absolute', right: 14, top: 40 },
link: { color: colors.teal700, fontWeight: '600', fontSize: fontSize.sm },
footer: { marginTop: 'auto', paddingTop: spacing[7], alignItems: 'center' },
footerText: { fontSize: fontSize.sm, color: colors.textMuted },
});
+88
View File
@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { api } from '@/api';
import { useT } from '@/i18n';
import { AuthShell } from '@/components/AuthShell';
import { Button, Field, TextField } from '@/components/ui';
import { CODE_LENGTH, CodeField } from '@/components/CodeField';
import { colors, fontSize, spacing } from '@/theme';
const MIN_PASSWORD = 8;
export default function ResetPasswordScreen() {
const { email } = useLocalSearchParams<{ email: string }>();
const router = useRouter();
const t = useT();
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [showPass, setShowPass] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit() {
if (code.length < CODE_LENGTH) return setError(t('verify.invalid'));
if (password.length < MIN_PASSWORD) {
return setError(t('register.shortPassword', { count: MIN_PASSWORD }));
}
setError(null);
setLoading(true);
try {
await api.auth.resetPassword(String(email), code, password);
// Every session was revoked server-side, so signing in again is the only
// way forward.
Alert.alert(t('reset.doneTitle'), t('reset.doneBody'), [
{ text: t('login.signIn'), onPress: () => router.replace('/login') },
]);
} catch (e: any) {
setError(e?.code === 'code_expired' ? t('verify.expired') : t('verify.invalid'));
setCode('');
} finally {
setLoading(false);
}
}
return (
<AuthShell
title={t('reset.title')}
subtitle={t('verify.subtitle', { email: String(email ?? '') })}
back
error={error}
>
<Field label={t('verify.code')}>
<CodeField value={code} onChange={setCode} autoFocus />
</Field>
<Field label={t('reset.newPassword')}>
<TextField
icon="lock-closed-outline"
value={password}
onChangeText={setPassword}
placeholder={t('register.passwordHint', { count: MIN_PASSWORD })}
secureTextEntry={!showPass}
autoCapitalize="none"
textContentType="newPassword"
/>
<Pressable onPress={() => setShowPass((v) => !v)} style={styles.showPass} hitSlop={8}>
<Text style={styles.link}>{showPass ? t('login.hide') : t('login.show')}</Text>
</Pressable>
</Field>
<Button
title={t('reset.submit')}
size="lg"
block
loading={loading}
onPress={onSubmit}
style={{ marginTop: spacing[2] }}
/>
</AuthShell>
);
}
const styles = StyleSheet.create({
showPass: { position: 'absolute', right: 14, top: 40 },
link: { color: colors.teal700, fontWeight: '600', fontSize: fontSize.sm },
});
+94
View File
@@ -0,0 +1,94 @@
import React, { useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { api } from '@/api';
import { useAuth } from '@/auth/AuthContext';
import { useT } from '@/i18n';
import { AuthShell } from '@/components/AuthShell';
import { Button, Field } from '@/components/ui';
import { CODE_LENGTH, CodeField } from '@/components/CodeField';
import { colors, fontSize, spacing } from '@/theme';
/** Matches the backend's resend throttle. */
const RESEND_COOLDOWN = 60;
export default function VerifyEmailScreen() {
const { email } = useLocalSearchParams<{ email: string }>();
const { signInWithVerification } = useAuth();
const t = useT();
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cooldown, setCooldown] = useState(RESEND_COOLDOWN);
const submitted = useRef(false);
useEffect(() => {
if (cooldown <= 0) return;
const id = setTimeout(() => setCooldown((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [cooldown]);
async function onVerify(value = code) {
if (value.length < CODE_LENGTH || submitted.current) return;
submitted.current = true;
setError(null);
setLoading(true);
try {
// The session starts here; the root layout moves us into the app.
await signInWithVerification(String(email), value);
} catch (e: any) {
// Wrong and expired are distinct, so the message can say which.
setError(e?.code === 'code_expired' ? t('verify.expired') : t('verify.invalid'));
setCode('');
} finally {
submitted.current = false;
setLoading(false);
}
}
async function onResend() {
if (cooldown > 0) return;
setError(null);
setCooldown(RESEND_COOLDOWN);
try {
await api.auth.resendVerification(String(email));
} catch {
// The endpoint always succeeds by design.
}
}
return (
<AuthShell
title={t('verify.title')}
subtitle={t('verify.subtitle', { email: String(email ?? '') })}
back
error={error}
>
<Field label={t('verify.code')}>
<CodeField value={code} onChange={setCode} onComplete={onVerify} autoFocus />
</Field>
<Button
title={t('verify.submit')}
size="lg"
block
loading={loading}
disabled={code.length < CODE_LENGTH}
onPress={() => onVerify()}
style={{ marginTop: spacing[2] }}
/>
<Pressable onPress={onResend} disabled={cooldown > 0} style={styles.resend}>
<Text style={[styles.resendText, cooldown > 0 && { color: colors.textMuted2 }]}>
{cooldown > 0 ? t('verify.resendIn', { count: cooldown }) : t('verify.resend')}
</Text>
</Pressable>
</AuthShell>
);
}
const styles = StyleSheet.create({
resend: { marginTop: spacing[5], alignItems: 'center' },
resendText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.teal700 },
});
+129
View File
@@ -0,0 +1,129 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { Tabs, useRouter } from 'expo-router';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { useT } from '@/i18n';
import { colors, fontSize, shadow, spacing } from '@/theme';
const FAB_SIZE = 58;
/** Width reserved for the centre action button, flanked by two tabs a side. */
const FAB_SLOT = 84;
const TAB_ICONS: Record<string, keyof typeof Ionicons.glyphMap> = {
index: 'home',
search: 'search',
expiring: 'time',
profile: 'person',
};
const TAB_LABEL_KEYS: Record<string, string> = {
index: 'tabs.home',
search: 'tabs.search',
expiring: 'tabs.expiring',
profile: 'tabs.profile',
};
function TabBar({ state, navigation }: BottomTabBarProps) {
const insets = useSafeAreaInsets();
const router = useRouter();
const t = useT();
const renderTab = (routeName: string) => {
const index = state.routes.findIndex((r) => r.name === routeName);
if (index < 0) return <View style={styles.tab} key={routeName} />;
const route = state.routes[index];
const focused = state.index === index;
return (
<Pressable
key={routeName}
style={styles.tab}
onPress={() => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
}}
>
<Ionicons
name={TAB_ICONS[routeName]}
size={22}
color={focused ? colors.teal600 : colors.slate400}
/>
<Text style={[styles.tabLabel, focused && { color: colors.teal600 }]} numberOfLines={1}>
{t(TAB_LABEL_KEYS[routeName])}
</Text>
</Pressable>
);
};
// Two tabs, action, two tabs: symmetric, so the button sits on the midpoint.
return (
<View style={[styles.bar, { paddingBottom: insets.bottom || spacing[3] }]}>
<View style={styles.row}>
<View style={styles.group}>
{renderTab('index')}
{renderTab('search')}
</View>
<View style={styles.fabSlot}>
<Pressable
style={({ pressed }) => [styles.fab, pressed && { opacity: 0.9 }]}
onPress={() => router.push('/upload')}
accessibilityLabel={t('tabs.upload')}
>
<Ionicons name="cloud-upload-outline" size={26} color={colors.onDark} />
</Pressable>
<Text style={styles.fabLabel} numberOfLines={1}>
{t('tabs.upload')}
</Text>
</View>
<View style={styles.group}>
{renderTab('expiring')}
{renderTab('profile')}
</View>
</View>
</View>
);
}
export default function TabsLayout() {
return (
<Tabs screenOptions={{ headerShown: false }} tabBar={(props) => <TabBar {...props} />}>
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="expiring" />
<Tabs.Screen name="profile" />
</Tabs>
);
}
const styles = StyleSheet.create({
bar: {
backgroundColor: colors.surface,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: spacing[2],
...shadow.sm,
},
row: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: spacing[1] },
group: { flex: 1, flexDirection: 'row' },
tab: { flex: 1, alignItems: 'center', gap: 3, paddingVertical: 4 },
tabLabel: { fontSize: fontSize['2xs'], fontWeight: '600', color: colors.slate400 },
fabSlot: { width: FAB_SLOT, alignItems: 'center' },
fab: {
width: FAB_SIZE,
height: FAB_SIZE,
borderRadius: FAB_SIZE / 2,
backgroundColor: colors.teal600,
alignItems: 'center',
justifyContent: 'center',
marginTop: -FAB_SIZE / 2,
...shadow.md,
},
fabLabel: { fontSize: fontSize['2xs'], fontWeight: '600', color: colors.teal700, marginTop: 3 },
});
+113
View File
@@ -0,0 +1,113 @@
import React, { useMemo, useState } from 'react';
import { FlatList, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { api, DocCategory } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Chip, EmptyState, Loading } from '@/components/ui';
import { DocumentRow } from '@/components/domain';
import { DocWithProperty, fetchAllDocuments } from '@/lib/allDocuments';
import { daysUntil } from '@/lib/format';
import { colors, fontSize, spacing } from '@/theme';
type Filter = 'all' | 'expiring' | 'expired';
export default function ExpiringScreen() {
const router = useRouter();
const t = useT();
const [filter, setFilter] = useState<Filter>('all');
const docsQuery = useQuery({ queryKey: ['all-documents'], queryFn: fetchAllDocuments });
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
const catName = (slug: string) =>
(catsQuery.data as DocCategory[] | undefined)?.find((c) => c.slug === slug)?.name ?? slug;
// Only documents that actually need attention, soonest deadline first.
const attention = useMemo(() => {
const docs: DocWithProperty[] = docsQuery.data ?? [];
return docs
.filter((d) => d.expiryStatus === 'expiring' || d.expiryStatus === 'expired')
.sort((a, b) => (daysUntil(a.expiryDate) ?? 0) - (daysUntil(b.expiryDate) ?? 0));
}, [docsQuery.data]);
const counts = useMemo(
() => ({
all: attention.length,
expiring: attention.filter((d) => d.expiryStatus === 'expiring').length,
expired: attention.filter((d) => d.expiryStatus === 'expired').length,
}),
[attention],
);
const visible = filter === 'all' ? attention : attention.filter((d) => d.expiryStatus === filter);
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar
title={t('expiring.title')}
subtitle={docsQuery.data ? t('expiring.subtitle', { count: counts.all }) : undefined}
large
/>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chips}
style={{ flexGrow: 0 }}
>
<Chip label={t('common.all')} count={counts.all} active={filter === 'all'} onPress={() => setFilter('all')} />
<Chip
label={t('documents.filterExpiring')}
count={counts.expiring}
active={filter === 'expiring'}
onPress={() => setFilter('expiring')}
/>
<Chip
label={t('documents.filterExpired')}
count={counts.expired}
active={filter === 'expired'}
onPress={() => setFilter('expired')}
/>
</ScrollView>
{docsQuery.isLoading ? (
<Loading />
) : (
<FlatList
data={visible}
keyExtractor={(d) => String(d.id)}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={docsQuery.isRefetching}
onRefresh={() => docsQuery.refetch()}
tintColor={colors.teal600}
/>
}
renderItem={({ item }) => (
<View style={{ marginBottom: spacing[3] }}>
<DocumentRow
document={item}
subtitle={`${item.propertyName} · ${catName(item.category)}`}
onPress={() => router.push(`/document/${item.id}?propertyId=${item.propertyId}`)}
/>
</View>
)}
ListEmptyComponent={
<EmptyState
icon="checkmark-circle-outline"
title={t('expiring.emptyTitle')}
subtitle={t('expiring.emptySubtitle')}
/>
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
chips: { gap: 8, paddingHorizontal: spacing[4], paddingVertical: spacing[2] },
list: { padding: spacing[4], paddingTop: spacing[2], paddingBottom: spacing[9] },
});
+108
View File
@@ -0,0 +1,108 @@
import React from 'react';
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { api, Property } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Button, EmptyState, IconButton, Loading } from '@/components/ui';
import { PropertyCard } from '@/components/domain';
import { colors, fontSize, radii, spacing } from '@/theme';
export default function PropertiesScreen() {
const router = useRouter();
const t = useT();
// One request: homes.php returns the per-property counters itself.
const {
data: properties,
isLoading,
isError,
error,
refetch,
isRefetching,
} = useQuery({ queryKey: ['properties'], queryFn: () => api.properties.list() });
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar
title={t('properties.title')}
subtitle={properties ? t('properties.count', { count: properties.length }) : undefined}
large
right={<IconButton name="search" onPress={() => router.push('/search')} />}
/>
{isLoading ? (
<Loading label={t('properties.loading')} />
) : isError ? (
<EmptyState
icon="cloud-offline-outline"
title={t('properties.loadError')}
subtitle={(error as Error)?.message}
action={<Button title={t('common.retry')} variant="ghost" onPress={() => refetch()} />}
/>
) : (
<FlatList
data={properties as Property[]}
keyExtractor={(p) => String(p.id)}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor={colors.teal600}
/>
}
ListHeaderComponent={
<View style={{ gap: spacing[3], marginBottom: spacing[4] }}>
<Pressable style={styles.searchBar} onPress={() => router.push('/search')}>
<Ionicons name="search" size={18} color={colors.slate400} />
<Text style={styles.searchPlaceholder}>{t('properties.searchPlaceholder')}</Text>
</Pressable>
<Button
title={t('properties.add')}
variant="ghost"
icon="add"
dashed
block
onPress={() => router.push('/property/add')}
/>
</View>
}
renderItem={({ item }) => (
<View style={{ marginBottom: spacing[3] }}>
<PropertyCard property={item} onPress={() => router.push(`/property/${item.id}`)} />
</View>
)}
ListEmptyComponent={
<EmptyState
icon="home-outline"
title={t('properties.emptyTitle')}
subtitle={t('properties.emptySubtitle')}
action={
<Button title={t('properties.add')} icon="add" onPress={() => router.push('/property/add')} />
}
/>
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
list: { padding: spacing[4], paddingBottom: spacing[9] },
searchBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
paddingHorizontal: spacing[4],
height: 48,
},
searchPlaceholder: { color: colors.textMuted2, fontSize: fontSize.sm, flex: 1 },
});
+260
View File
@@ -0,0 +1,260 @@
import React, { useState } from 'react';
import {
ActivityIndicator,
Image,
Alert,
Linking,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { api, USE_MOCK } from '@/api';
import { AppBar } from '@/components/AppBar';
import { Avatar, Badge, Button, Card, SectionTitle } from '@/components/ui';
import { useAuth } from '@/auth/AuthContext';
import { LanguagePreference, useI18n } from '@/i18n';
import { colors, fontSize, radii, spacing } from '@/theme';
// Both stores check these open a real page before accepting a build.
const PRIVACY_URL = 'https://casadoc.app/privacy';
const TERMS_URL = 'https://casadoc.app/terms';
export default function ProfileScreen() {
const { user, signOut, deleteAccount } = useAuth();
const router = useRouter();
const { t, preference, setPreference } = useI18n();
const [signingOut, setSigningOut] = useState(false);
const [deleting, setDeleting] = useState(false);
const propsQuery = useQuery({ queryKey: ['properties'], queryFn: () => api.properties.list() });
const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.email || '—';
const initials = ((user?.firstName?.[0] ?? '') + (user?.lastName?.[0] ?? '')).toUpperCase() || 'U';
const languageOptions: { value: LanguagePreference; label: string; sub?: string }[] = [
{ value: 'system', label: t('profile.languageSystem'), sub: t('profile.languageSystemSub') },
{ value: 'en', label: 'English' },
{ value: 'it', label: 'Italiano' },
];
// Both stores require in-app deletion, with the consequences spelled out.
function confirmDelete() {
Alert.alert(t('profile.deleteConfirmTitle'), t('profile.deleteConfirmBody'), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('profile.deleteConfirmCta'),
style: 'destructive',
onPress: async () => {
setDeleting(true);
try {
await deleteAccount();
} catch (e: any) {
Alert.alert(t('profile.deleteFailed'), e?.message ?? t('common.tryAgain'));
} finally {
setDeleting(false);
}
},
},
]);
}
async function onSignOut() {
setSigningOut(true);
try {
await signOut();
} finally {
setSigningOut(false);
}
}
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar title={t('profile.title')} large />
<ScrollView contentContainerStyle={styles.body}>
<Card style={{ padding: spacing[5] }}>
<View style={styles.hero}>
{user?.avatar ? (
<Image source={{ uri: user.avatar }} style={styles.heroAvatar} />
) : (
<Avatar initials={initials} size={60} />
)}
<View style={{ flex: 1 }}>
<Text style={styles.name}>{fullName}</Text>
<Text style={styles.email}>{user?.email}</Text>
<View style={{ flexDirection: 'row', marginTop: 8 }}>
<Badge
label={t('properties.count', { count: propsQuery.data?.length ?? 0 })}
tone="info"
/>
</View>
</View>
</View>
</Card>
{/* Language */}
<SectionTitle title={t('profile.language')} />
<Card style={{ padding: 0 }}>
{languageOptions.map((opt, i) => {
const selected = preference === opt.value;
return (
<React.Fragment key={opt.value}>
{i > 0 ? <Divider /> : null}
<Pressable
style={styles.row}
onPress={() => setPreference(opt.value)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.rowIcon}>
<Ionicons
name={opt.value === 'system' ? 'phone-portrait-outline' : 'language-outline'}
size={18}
color={colors.teal600}
/>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.rowTitle}>{opt.label}</Text>
{opt.sub ? <Text style={styles.rowSub}>{opt.sub}</Text> : null}
</View>
<View style={[styles.radio, selected && styles.radioOn]}>
{selected ? <View style={styles.radioDot} /> : null}
</View>
</Pressable>
</React.Fragment>
);
})}
</Card>
{/* Account */}
<SectionTitle title={t('profile.account')} />
<Card style={{ padding: 0 }}>
<Pressable style={styles.row} onPress={() => router.push('/profile/edit')}>
<View style={styles.rowIcon}>
<Ionicons name="person-outline" size={18} color={colors.teal600} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.rowTitle}>{t('profile.personalDetails')}</Text>
<Text style={styles.rowSub}>{t('profile.personalDetailsSub')}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.slate400} />
</Pressable>
<Divider />
<Pressable style={styles.row} onPress={() => router.push('/profile/password')}>
<View style={styles.rowIcon}>
<Ionicons name="lock-closed-outline" size={18} color={colors.teal600} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.rowTitle}>{t('profile.security')}</Text>
<Text style={styles.rowSub}>{t('profile.securitySub')}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.slate400} />
</Pressable>
</Card>
{/* Legal — store listings require these to be reachable. */}
<SectionTitle title={t('profile.legal')} />
<Card style={{ padding: 0 }}>
<Pressable style={styles.row} onPress={() => Linking.openURL(PRIVACY_URL)}>
<View style={styles.rowIcon}>
<Ionicons name="shield-checkmark-outline" size={18} color={colors.teal600} />
</View>
<Text style={[styles.rowTitle, { flex: 1 }]}>{t('profile.privacy')}</Text>
<Ionicons name="open-outline" size={18} color={colors.slate400} />
</Pressable>
<Divider />
<Pressable style={styles.row} onPress={() => Linking.openURL(TERMS_URL)}>
<View style={styles.rowIcon}>
<Ionicons name="document-text-outline" size={18} color={colors.teal600} />
</View>
<Text style={[styles.rowTitle, { flex: 1 }]}>{t('profile.terms')}</Text>
<Ionicons name="open-outline" size={18} color={colors.slate400} />
</Pressable>
</Card>
{USE_MOCK ? <Text style={styles.demo}>{t('profile.demoMode')}</Text> : null}
<SectionTitle title={t('profile.dangerZone')} />
<Pressable style={styles.dangerRow} onPress={deleting ? undefined : confirmDelete}>
<View style={styles.dangerIcon}>
<Ionicons name="trash-outline" size={18} color={colors.danger600} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.dangerTitle}>{t('profile.deleteAccount')}</Text>
<Text style={styles.rowSub}>{t('profile.deleteAccountSub')}</Text>
</View>
{deleting ? <ActivityIndicator color={colors.danger600} /> : null}
</Pressable>
<Button
title={t('profile.signOut')}
variant="ghost"
icon="log-out-outline"
block
loading={signingOut}
onPress={onSignOut}
style={{ marginTop: spacing[5] }}
/>
</ScrollView>
</View>
);
}
function Divider() {
return <View style={styles.divider} />;
}
const styles = StyleSheet.create({
body: { padding: spacing[4], paddingBottom: spacing[9] },
heroAvatar: { width: 60, height: 60, borderRadius: 30, backgroundColor: colors.surfaceSunken },
hero: { flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
name: { fontSize: fontSize.lg, fontWeight: '700', color: colors.ink },
email: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: 2 },
row: { flexDirection: 'row', alignItems: 'center', gap: spacing[3], padding: spacing[4] },
rowIcon: {
width: 38,
height: 38,
borderRadius: 10,
backgroundColor: colors.teal50,
alignItems: 'center',
justifyContent: 'center',
},
rowTitle: { fontSize: fontSize.base, fontWeight: '600', color: colors.ink },
rowSub: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 2 },
divider: { height: 1, backgroundColor: colors.border, marginLeft: 58 },
radio: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: colors.borderStrong,
alignItems: 'center',
justifyContent: 'center',
},
radioOn: { borderColor: colors.teal600 },
radioDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: colors.teal600 },
dangerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[3],
padding: spacing[4],
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#f0c2c9',
borderRadius: radii.lg,
},
dangerIcon: {
width: 38,
height: 38,
borderRadius: 10,
backgroundColor: colors.dangerBg,
alignItems: 'center',
justifyContent: 'center',
},
dangerTitle: { fontSize: fontSize.base, fontWeight: '700', color: colors.danger600 },
demo: { textAlign: 'center', color: colors.textMuted2, fontSize: fontSize.xs, marginTop: spacing[5] },
});
+106
View File
@@ -0,0 +1,106 @@
import React, { useMemo, useState } from 'react';
import { FlatList, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { api, DocCategory } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Chip, EmptyState, Loading, TextField } from '@/components/ui';
import { DocumentRow } from '@/components/domain';
import { DocWithProperty, fetchAllDocuments } from '@/lib/allDocuments';
import { colors, fontSize, spacing } from '@/theme';
export default function SearchScreen() {
const router = useRouter();
const t = useT();
const [query, setQuery] = useState('');
const [category, setCategory] = useState<string>('all');
const docsQuery = useQuery({ queryKey: ['all-documents'], queryFn: fetchAllDocuments });
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
const categories: DocCategory[] = catsQuery.data ?? [];
const catName = (slug: string) => categories.find((c) => c.slug === slug)?.name ?? slug;
const results = useMemo(() => {
const docs: DocWithProperty[] = docsQuery.data ?? [];
const q = query.trim().toLowerCase();
return docs.filter((d) => {
const matchesCat = category === 'all' || d.category === category;
const matchesQuery =
!q || d.name.toLowerCase().includes(q) || catName(d.category).toLowerCase().includes(q);
return matchesCat && matchesQuery;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docsQuery.data, query, category, categories]);
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar title={t('tabs.search')} large />
<View style={styles.controls}>
<TextField
icon="search"
value={query}
onChangeText={setQuery}
placeholder={t('properties.searchPlaceholder')}
autoCapitalize="none"
autoFocus
/>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chips}
>
<Chip label={t('common.all')} active={category === 'all'} onPress={() => setCategory('all')} />
{categories.map((c) => (
<Chip
key={c.slug}
label={c.name}
active={category === c.slug}
onPress={() => setCategory(c.slug)}
/>
))}
</ScrollView>
</View>
{docsQuery.isLoading ? (
<Loading />
) : (
<FlatList
data={results}
keyExtractor={(d) => String(d.id)}
contentContainerStyle={styles.list}
ListHeaderComponent={
<Text style={styles.count}>
{t('properties.docCount', { count: results.length })}
</Text>
}
renderItem={({ item }) => (
<View style={{ marginBottom: spacing[3] }}>
<DocumentRow
document={item}
subtitle={`${item.propertyName} · ${catName(item.category)}`}
onPress={() => router.push(`/document/${item.id}?propertyId=${item.propertyId}`)}
/>
</View>
)}
ListEmptyComponent={
<EmptyState
icon="search-outline"
title={t('documents.emptyTitle')}
subtitle={t('documents.emptySubtitle')}
/>
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
controls: { paddingHorizontal: spacing[4], gap: spacing[3], paddingBottom: spacing[2] },
chips: { gap: 8, paddingVertical: spacing[1], paddingRight: spacing[4] },
list: { padding: spacing[4], paddingTop: spacing[2], paddingBottom: spacing[9] },
count: { fontSize: fontSize.sm, color: colors.slate500, marginBottom: spacing[3], fontWeight: '600' },
});
+89
View File
@@ -0,0 +1,89 @@
import 'react-native-gesture-handler';
import React, { useEffect } from 'react';
import { View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import { Stack, useRouter, useSegments } from 'expo-router';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { AuthProvider, useAuth } from '@/auth/AuthContext';
import { I18nProvider } from '@/i18n';
import { Loading } from '@/components/ui';
import { OfflineGate } from '@/components/OfflineBanner';
import { persister, queryClient } from '@/lib/queryClient';
import { startNetworkWatcher } from '@/lib/network';
import { colors } from '@/theme';
// Before React renders, so the first queries already know the network state.
startNetworkWatcher();
/** Routes between the auth flow and the app. */
function useProtectedRoute() {
const { user, initializing } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (initializing) return;
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
router.replace('/(tabs)');
}
}, [user, initializing, segments]);
}
function RootNavigator() {
const { initializing } = useAuth();
useProtectedRoute();
if (initializing) {
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<Loading label="CasaDoc" />
</View>
);
}
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bgApp } }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="property/[id]" />
<Stack.Screen name="property/add" />
<Stack.Screen name="documents/[propertyId]" />
<Stack.Screen name="document/[id]" />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/password" />
<Stack.Screen name="upload" options={{ presentation: 'modal' }} />
</Stack>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 7 * 24 * 60 * 60 * 1000,
// Reads only: a half-finished upload must not replay on launch.
dehydrateOptions: { shouldDehydrateMutation: () => false },
}}
>
<I18nProvider>
<AuthProvider>
<StatusBar style="dark" />
<OfflineGate>
<RootNavigator />
</OfflineGate>
</AuthProvider>
</I18nProvider>
</PersistQueryClientProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
+298
View File
@@ -0,0 +1,298 @@
import React, { useState } from 'react';
import { Alert, Image, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, DocCategory, Document } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Badge, Button, Card, IconButton, Loading } from '@/components/ui';
import { CategoryPill, ExpiryBadge } from '@/components/domain';
import { PdfView } from '@/components/PdfView';
import { getLocalFile, openInSystemViewer, shareDocument } from '@/lib/documentFile';
import { formatBytes, formatDate } from '@/lib/format';
import { colors, fontSize, radii, spacing } from '@/theme';
export default function DocumentDetailScreen() {
const { id, propertyId } = useLocalSearchParams<{ id: string; propertyId?: string }>();
const docId = Number(id);
const pid = propertyId ? Number(propertyId) : undefined;
const router = useRouter();
const qc = useQueryClient();
const t = useT();
const [deleting, setDeleting] = useState(false);
const [busy, setBusy] = useState<'open' | 'share' | null>(null);
// Prefer the cached list entry; fetch only if we arrived without one.
const listQuery = useQuery({
queryKey: ['documents', pid],
queryFn: () => api.documents.listByProperty(pid as number),
enabled: pid != null,
});
const cached = listQuery.data?.find((d: Document) => d.id === docId);
const singleQuery = useQuery({
queryKey: ['document', docId],
queryFn: () => api.documents.get(docId),
enabled: !cached && pid == null,
});
const doc: Document | undefined = cached ?? singleQuery.data;
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
const catName = (slug: string) =>
catsQuery.data?.find((c: DocCategory) => c.slug === slug)?.name ?? slug;
// Downloaded with the bearer header, for the preview and the phone's viewer.
const fileQuery = useQuery({
queryKey: ['document-file', docId],
queryFn: () => getLocalFile(doc as Document),
enabled: !!doc?.fileUri,
retry: false,
});
const removeMutation = useMutation({
mutationFn: () => api.documents.remove(docId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documents'] });
qc.invalidateQueries({ queryKey: ['all-documents'] });
qc.invalidateQueries({ queryKey: ['properties'] });
router.back();
},
});
function confirmDelete() {
Alert.alert(t('document.deleteTitle'), t('document.deleteBody'), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: () => {
setDeleting(true);
removeMutation.mutate(undefined, { onSettled: () => setDeleting(false) });
},
},
]);
}
async function onOpen() {
if (!doc) return;
setBusy('open');
try {
await openInSystemViewer(doc);
} catch (e: any) {
Alert.alert(t('document.cannotOpen'), e?.message ?? t('document.cannotOpenBody'));
} finally {
setBusy(null);
}
}
async function onShare() {
if (!doc) return;
setBusy('share');
try {
await shareDocument(doc);
} catch (e: any) {
Alert.alert(t('document.cannotSave'), e?.message ?? t('document.cannotSaveBody'));
} finally {
setBusy(null);
}
}
if (!doc) {
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar title={t('document.title')} back />
<Loading />
</View>
);
}
const isImage = doc.mimeType.startsWith('image/');
const fileReady = !!fileQuery.data;
const fileError = fileQuery.error as Error | undefined;
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar
title={t('document.title')}
back
right={
<IconButton
name="create-outline"
onPress={() => router.push(`/document/edit/${docId}?propertyId=${pid ?? ''}`)}
/>
}
/>
<ScrollView contentContainerStyle={styles.body}>
{doc.expiryStatus === 'expiring' || doc.expiryStatus === 'expired' ? (
<Card
// The banner is about the expiry date; go where it can be changed.
onPress={() => router.push(`/document/edit/${docId}?propertyId=${pid ?? ''}`)}
style={[
styles.banner,
doc.expiryStatus === 'expired'
? { backgroundColor: colors.dangerBg, borderColor: '#f0c2c9' }
: { backgroundColor: colors.warningBg, borderColor: '#f0dcae' },
]}
>
<Ionicons
name={doc.expiryStatus === 'expired' ? 'alert-circle-outline' : 'time-outline'}
size={20}
color={doc.expiryStatus === 'expired' ? colors.dangerText : colors.warningText}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.bannerTitle, { color: doc.expiryStatus === 'expired' ? colors.dangerText : colors.warningText }]}>
{doc.expiryStatus === 'expired' ? t('document.expired') : t('document.expiringSoon')}
</Text>
<Text style={[styles.bannerText, { color: doc.expiryStatus === 'expired' ? colors.dangerText : colors.warningText }]}>
{doc.expiryDate ? t('document.expiresOn', { date: formatDate(doc.expiryDate) }) : t('document.checkExpiry')}
</Text>
</View>
<Ionicons
name="chevron-forward"
size={18}
color={doc.expiryStatus === 'expired' ? colors.dangerText : colors.warningText}
/>
</Card>
) : null}
{/* Preview — always from the downloaded local copy, never a raw URL. */}
<View style={styles.preview}>
{fileQuery.isLoading ? (
<View style={styles.previewBox}>
<Loading label={t('document.loadingFile')} />
</View>
) : fileError ? (
<View style={styles.previewBox}>
<Ionicons name="cloud-offline-outline" size={30} color={colors.slate400} />
<Text style={styles.previewErr}>{fileError.message}</Text>
</View>
) : isImage && fileReady ? (
<Image source={{ uri: fileQuery.data }} style={styles.previewImage} resizeMode="contain" />
) : fileReady ? (
<PdfView ready onPress={onOpen} style={{ width: '100%' }} />
) : (
<SkeletonPage />
)}
{doc.pageCount ? (
<View style={styles.pageBadge}>
<Badge label={t('document.pages', { count: doc.pageCount })} tone="neutral" />
</View>
) : null}
</View>
<Text style={styles.name}>{doc.name}</Text>
<View style={styles.tagRow}>
<CategoryPill slug={doc.category} name={catName(doc.category)} />
<ExpiryBadge document={doc} />
</View>
<Card style={{ marginTop: spacing[4] }}>
<View style={styles.metaGrid}>
<Meta k={t('document.uploaded')} v={formatDate(doc.uploadedAt)} />
<Meta
k={t('document.expires')}
v={doc.expiryDate ? formatDate(doc.expiryDate) : t('editDocument.noExpiry')}
/>
<Meta k={t('document.fileSize')} v={formatBytes(doc.sizeBytes)} />
<Meta k={t('document.format')} v={isImage ? t('document.formatImage') : t('document.formatPdf')} />
</View>
</Card>
<View style={styles.actions}>
<Button
title={t('document.share')}
variant="ghost"
icon="share-outline"
style={{ flex: 1 }}
loading={busy === 'share'}
disabled={!fileReady}
onPress={onShare}
/>
<Button
title={t('common.delete')}
variant="danger"
icon="trash-outline"
style={{ flex: 1 }}
loading={deleting}
onPress={confirmDelete}
/>
</View>
<Button
title={t('document.openOnPhone')}
icon="phone-portrait-outline"
block
loading={busy === 'open'}
disabled={!fileReady}
onPress={onOpen}
style={{ marginTop: spacing[3] }}
/>
</ScrollView>
</View>
);
}
function Meta({ k, v }: { k: string; v: string }) {
return (
<View style={styles.metaItem}>
<Text style={styles.metaK}>{k}</Text>
<Text style={styles.metaV}>{v}</Text>
</View>
);
}
function SkeletonPage() {
return (
<View style={styles.skeleton}>
{[0.9, 0.6, 0.95, 0.8, 0.95, 0.7, 0.85].map((w, i) => (
<View key={i} style={[styles.skelBar, { width: `${w * 100}%` }, i === 0 && { height: 12, marginBottom: 12 }]} />
))}
</View>
);
}
const styles = StyleSheet.create({
body: { padding: spacing[4], paddingBottom: spacing[9] },
banner: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing[3], marginBottom: spacing[4] },
bannerTitle: { fontSize: fontSize.md, fontWeight: '700' },
bannerText: { fontSize: fontSize.sm, opacity: 0.85, marginTop: 2 },
preview: { alignItems: 'center', justifyContent: 'center', marginBottom: spacing[4] },
previewBox: {
width: '100%',
height: 220,
borderRadius: radii.md,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
gap: spacing[2],
padding: spacing[5],
},
previewErr: { fontSize: fontSize.sm, color: colors.textMuted, textAlign: 'center' },
previewImage: {
width: '100%',
height: 320,
borderRadius: radii.md,
backgroundColor: colors.surface,
},
pageBadge: { marginTop: spacing[3] },
name: { fontSize: fontSize.xl, fontWeight: '700', color: colors.ink },
tagRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: spacing[2] },
metaGrid: { flexDirection: 'row', flexWrap: 'wrap' },
metaItem: { width: '50%', paddingVertical: spacing[2] },
metaK: { fontSize: fontSize.xs, color: colors.textMuted2, textTransform: 'uppercase', letterSpacing: 0.3 },
metaV: { fontSize: fontSize.base, color: colors.ink, fontWeight: '600', marginTop: 2 },
actions: { flexDirection: 'row', gap: spacing[3], marginTop: spacing[4] },
skeleton: {
width: 230,
height: 300,
backgroundColor: colors.surface,
borderRadius: radii.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing[5],
justifyContent: 'flex-start',
},
skelBar: { height: 8, borderRadius: 4, backgroundColor: colors.surfaceSunken, marginBottom: 9 },
});
+283
View File
@@ -0,0 +1,283 @@
import React, { useEffect, useState } from 'react';
import { Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as DocumentPicker from 'expo-document-picker';
import { Ionicons } from '@expo/vector-icons';
import { api, DocCategory, Document, LocalFile } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Button, Field, Loading, TextField } from '@/components/ui';
import { DateField } from '@/components/DateField';
import { FileGlyph } from '@/components/domain';
import { clearCachedFile } from '@/lib/documentFile';
import { pickImageFromLibrary } from '@/lib/pickImage';
import { isCameraBlocked, openPermissionSettings } from '@/lib/permissions';
import { scanDocumentToPdf } from '@/scanner/scanToPdf';
import { formatBytes } from '@/lib/format';
import { categoryColor, colors, fontSize, radii, spacing } from '@/theme';
export default function EditDocumentScreen() {
const { id, propertyId } = useLocalSearchParams<{ id: string; propertyId?: string }>();
const docId = Number(id);
const pid = propertyId ? Number(propertyId) : undefined;
const router = useRouter();
const qc = useQueryClient();
const t = useT();
// From the property's cached list: the backend has no per-file GET.
const listQuery = useQuery({
queryKey: ['documents', pid],
queryFn: () => api.documents.listByProperty(pid as number),
enabled: pid != null,
});
const doc: Document | undefined = listQuery.data?.find((d: Document) => d.id === docId);
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
const categories: DocCategory[] = catsQuery.data ?? [];
const [expiry, setExpiry] = useState<string | null>(null);
const [note, setNote] = useState('');
const [title, setTitle] = useState('');
const [category, setCategory] = useState<string>('');
const [loaded, setLoaded] = useState(false);
const [replacing, setReplacing] = useState(false);
useEffect(() => {
if (!doc || loaded) return;
setExpiry(doc.expiryDate ?? null);
setNote(doc.note ?? '');
setTitle(doc.name ?? '');
setCategory(doc.category);
setLoaded(true);
}, [doc, loaded]);
const save = useMutation({
mutationFn: () =>
api.documents.update({
id: docId,
title: title.trim() || null,
expiryDate: expiry,
note: note.trim() || null,
// Re-filing costs an extra backend lookup, so only send it if changed.
...(doc && category !== doc.category ? { category } : {}),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documents'] });
qc.invalidateQueries({ queryKey: ['all-documents'] });
router.back();
},
onError: (e: any) => Alert.alert(t('editDocument.failed'), e?.message ?? t('common.tryAgain')),
});
// A separate endpoint from the metadata update, so it applies immediately.
async function replaceWith(file: LocalFile | null) {
if (!file || !doc) return;
setReplacing(true);
try {
await api.documents.replaceFile(doc.id, file);
await clearCachedFile(doc); // otherwise the old copy stays on screen
qc.invalidateQueries({ queryKey: ['documents'] });
qc.invalidateQueries({ queryKey: ['all-documents'] });
qc.removeQueries({ queryKey: ['document-file', doc.id] });
Alert.alert(t('editDocument.replaced'));
} catch (e: any) {
Alert.alert(t('editDocument.replaceFailed'), e?.message ?? t('common.tryAgain'));
} finally {
setReplacing(false);
}
}
async function replaceByScan() {
try {
const result = await scanDocumentToPdf(doc?.name || t('upload.scannedName'));
await replaceWith(result?.file ?? null);
} catch (e: any) {
if (await isCameraBlocked()) openPermissionSettings('permissions.cameraBody');
else Alert.alert(t('upload.scannerUnavailable'), e?.message ?? t('upload.scannerUnavailableBody'));
}
}
async function replaceByFile() {
const res = await DocumentPicker.getDocumentAsync({
type: ['application/pdf', 'image/*'],
copyToCacheDirectory: true,
});
if (res.canceled || !res.assets?.length) return;
const a = res.assets[0];
await replaceWith({
uri: a.uri,
name: a.name,
mimeType: a.mimeType ?? 'application/octet-stream',
sizeBytes: a.size ?? 0,
});
}
async function replaceByPhoto() {
await replaceWith(await pickImageFromLibrary({ quality: 0.9, fallbackName: 'photo' }));
}
if (!doc) {
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar title={t('editDocument.title')} back />
<Loading />
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bgApp }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<AppBar title={t('editDocument.title')} subtitle={doc.name} back />
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{/* Current file + replacement */}
<Field label={t('editDocument.file')}>
<View style={styles.fileCard}>
<FileGlyph mimeType={doc.mimeType} size={38} />
<View style={{ flex: 1, marginLeft: spacing[3] }}>
<Text style={styles.fileName} numberOfLines={1}>
{doc.name}
</Text>
<Text style={styles.fileMeta}>{formatBytes(doc.sizeBytes)}</Text>
</View>
</View>
<View style={styles.replaceRow}>
<ReplaceButton icon="scan-outline" label={t('upload.scan')} onPress={replaceByScan} busy={replacing} />
<ReplaceButton icon="document-outline" label={t('upload.chooseFile')} onPress={replaceByFile} busy={replacing} />
<ReplaceButton icon="image-outline" label={t('upload.gallery')} onPress={replaceByPhoto} busy={replacing} />
</View>
<Text style={styles.replaceHint}>{t('editDocument.replaceHint')}</Text>
</Field>
<Field label={t('editDocument.name')}>
<TextField
value={title}
onChangeText={setTitle}
placeholder={t('editDocument.namePlaceholder')}
autoCapitalize="sentences"
/>
</Field>
<Field label={t('upload.expiry')} optional>
<DateField
value={expiry}
onChange={setExpiry}
placeholder={t('editDocument.noExpiry')}
/>
</Field>
<Field label={t('upload.category')}>
<View style={styles.catWrap}>
{categories.map((c) => {
const active = c.slug === category;
return (
<Pressable
key={c.slug}
onPress={() => setCategory(c.slug)}
style={[
styles.catChip,
active && { backgroundColor: categoryColor(c.slug) + '18', borderColor: categoryColor(c.slug) },
]}
>
<View style={[styles.catDot, { backgroundColor: categoryColor(c.slug) }]} />
<Text style={[styles.catText, active && { color: categoryColor(c.slug) }]}>{c.name}</Text>
</Pressable>
);
})}
</View>
</Field>
<Field label={t('editDocument.note')} optional>
<TextField
value={note}
onChangeText={setNote}
placeholder={t('editDocument.notePlaceholder')}
multiline
numberOfLines={4}
style={{ minHeight: 90, textAlignVertical: 'top' }}
/>
</Field>
<Text style={styles.hint}>{t('editDocument.hint')}</Text>
<Button
title={t('editDocument.save')}
icon="checkmark"
size="lg"
block
loading={save.isPending}
onPress={() => save.mutate()}
style={{ marginTop: spacing[2] }}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
function ReplaceButton({
icon,
label,
onPress,
busy,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress: () => void;
busy?: boolean;
}) {
return (
<Pressable
onPress={busy ? undefined : onPress}
style={({ pressed }) => [styles.replaceBtn, (pressed || busy) && { opacity: 0.6 }]}
>
<Ionicons name={icon} size={20} color={colors.teal600} />
<Text style={styles.replaceLabel} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
body: { padding: spacing[4], paddingBottom: spacing[9] },
catWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
catChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 7,
paddingHorizontal: 12,
height: 36,
borderRadius: radii.pill,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
},
catDot: { width: 8, height: 8, borderRadius: 4 },
catText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.slate600 },
hint: { fontSize: fontSize.xs, color: colors.textMuted, marginBottom: spacing[3] },
fileCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surfaceSunken,
borderRadius: radii.md,
padding: spacing[3],
},
fileName: { fontSize: fontSize.sm, fontWeight: '600', color: colors.ink },
fileMeta: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 2 },
replaceRow: { flexDirection: 'row', gap: spacing[2], marginTop: spacing[3] },
replaceBtn: {
flex: 1,
alignItems: 'center',
gap: 6,
paddingVertical: spacing[3],
borderRadius: radii.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
},
replaceLabel: { fontSize: fontSize.xs, fontWeight: '600', color: colors.ink },
replaceHint: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: spacing[2] },
});
+120
View File
@@ -0,0 +1,120 @@
import React, { useMemo, useState } from 'react';
import { FlatList, ScrollView, StyleSheet, View } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { api, DocCategory, Document } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Chip, EmptyState, IconButton, Loading } from '@/components/ui';
import { DocumentRow } from '@/components/domain';
import { colors, spacing } from '@/theme';
type StatusFilter = 'all' | 'attention' | 'valid' | 'expiring' | 'expired';
const STATUSES: StatusFilter[] = ['all', 'attention', 'valid', 'expiring', 'expired'];
export default function DocumentsScreen() {
const { propertyId, category, status: initialStatus } = useLocalSearchParams<{
propertyId: string;
category?: string;
status?: string;
}>();
const pid = Number(propertyId);
const router = useRouter();
const t = useT();
// Links can preselect a filter (e.g. the "needs attention" banner).
const [status, setStatus] = useState<StatusFilter>(
STATUSES.includes(initialStatus as StatusFilter) ? (initialStatus as StatusFilter) : 'all',
);
const docsQuery = useQuery({ queryKey: ['documents', pid], queryFn: () => api.documents.listByProperty(pid) });
const propertyQuery = useQuery({ queryKey: ['property', pid], queryFn: () => api.properties.get(pid) });
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
const categories: DocCategory[] = catsQuery.data ?? [];
const catName = (slug: string) => categories.find((c) => c.slug === slug)?.name ?? slug;
const inCategory = useMemo<Document[]>(
() => ((docsQuery.data ?? []) as Document[]).filter((d) => !category || d.category === category),
[docsQuery.data, category],
);
const counts = useMemo(() => {
const c = { all: inCategory.length, attention: 0, valid: 0, expiring: 0, expired: 0 };
for (const d of inCategory) {
if (d.expiryStatus === 'valid') c.valid += 1;
else if (d.expiryStatus === 'expiring') c.expiring += 1;
else if (d.expiryStatus === 'expired') c.expired += 1;
}
c.attention = c.expiring + c.expired;
return c;
}, [inCategory]);
const visible = useMemo(() => {
if (status === 'all') return inCategory;
if (status === 'attention') {
return inCategory.filter((d) => d.expiryStatus === 'expiring' || d.expiryStatus === 'expired');
}
return inCategory.filter((d) => d.expiryStatus === status);
}, [inCategory, status]);
const title = category ? catName(category) : t('documents.allTitle');
const subtitle = `${propertyQuery.data?.name ?? ''} · ${t('properties.docCount', { count: inCategory.length })}`;
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar
title={title}
subtitle={subtitle}
back
right={<IconButton name="search" onPress={() => router.push('/search')} />}
/>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chips}
style={{ flexGrow: 0 }}
>
<Chip label={t('common.all')} count={counts.all} active={status === 'all'} onPress={() => setStatus('all')} />
<Chip
label={t('documents.filterAttention')}
count={counts.attention}
active={status === 'attention'}
onPress={() => setStatus('attention')}
/>
<Chip label={t('documents.filterValid')} count={counts.valid} active={status === 'valid'} onPress={() => setStatus('valid')} />
<Chip label={t('documents.filterExpiring')} count={counts.expiring} active={status === 'expiring'} onPress={() => setStatus('expiring')} />
<Chip label={t('documents.filterExpired')} count={counts.expired} active={status === 'expired'} onPress={() => setStatus('expired')} />
</ScrollView>
{docsQuery.isLoading ? (
<Loading />
) : (
<FlatList
data={visible as Document[]}
keyExtractor={(d) => String(d.id)}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={{ marginBottom: spacing[3] }}>
<DocumentRow
document={item}
categoryName={catName(item.category)}
showCategory={!category}
onPress={() => router.push(`/document/${item.id}?propertyId=${pid}`)}
/>
</View>
)}
ListEmptyComponent={
<EmptyState icon="documents-outline" title={t('documents.emptyTitle')} subtitle={t('documents.emptySubtitle')} />
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
chips: { gap: 8, paddingHorizontal: spacing[4], paddingVertical: spacing[2] },
list: { padding: spacing[4], paddingTop: spacing[2], paddingBottom: spacing[9] },
});
+6
View File
@@ -0,0 +1,6 @@
import { Redirect } from 'expo-router';
// The auth gate in the root layout decides between the login flow and the tabs.
export default function Index() {
return <Redirect href="/(tabs)" />;
}
+172
View File
@@ -0,0 +1,172 @@
import React, { useEffect, useState } from 'react';
import { Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Image } from 'react-native';
import { useRouter } from 'expo-router';
import { useMutation } from '@tanstack/react-query';
import { Ionicons } from '@expo/vector-icons';
import { api, LocalFile } from '@/api';
import { useAuth } from '@/auth/AuthContext';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Avatar, Button, Field, TextField } from '@/components/ui';
import { pickImageFromLibrary, takePhotoWithCamera } from '@/lib/pickImage';
import { colors, fontSize, spacing } from '@/theme';
export default function EditProfileScreen() {
const { user, applyUser } = useAuth();
const router = useRouter();
const t = useT();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [address, setAddress] = useState('');
const [avatar, setAvatar] = useState<LocalFile | null>(null);
useEffect(() => {
if (!user) return;
setFirstName(user.firstName ?? '');
setLastName(user.lastName ?? '');
setPhone(user.phone ?? '');
setAddress(user.address ?? '');
}, [user]);
const save = useMutation({
mutationFn: async () => {
const updated = await api.auth.updateProfile({
firstName: firstName.trim() || null,
lastName: lastName.trim() || null,
phone: phone.trim() || null,
address: address.trim() || null,
});
// The avatar endpoint answers last, so its user object is the newer one.
if (avatar) {
try {
return await api.auth.uploadAvatar(avatar);
} catch {
Alert.alert(t('addProperty.photoFailed'));
}
}
return updated;
},
onSuccess: (updated) => {
applyUser(updated);
router.back();
},
onError: (e: any) => Alert.alert(t('editProfile.failed'), e?.message ?? t('common.tryAgain')),
});
function pickAvatar() {
Alert.alert(t('editProfile.photo'), undefined, [
{
text: t('addProperty.takePhoto'),
onPress: async () => {
const picked = await takePhotoWithCamera({ allowsEditing: true, aspect: [1, 1], fallbackName: 'avatar' });
if (picked) setAvatar(picked);
},
},
{
text: t('addProperty.fromGallery'),
onPress: async () => {
const picked = await pickImageFromLibrary({ allowsEditing: true, aspect: [1, 1], fallbackName: 'avatar' });
if (picked) setAvatar(picked);
},
},
{ text: t('common.cancel'), style: 'cancel' },
]);
}
const initials = ((firstName[0] ?? '') + (lastName[0] ?? '')).toUpperCase() || 'U';
const avatarUri = avatar?.uri ?? user?.avatar ?? null;
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bgApp }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<AppBar title={t('editProfile.title')} back />
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
<Pressable style={styles.avatarWrap} onPress={pickAvatar}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : (
<Avatar initials={initials} size={92} />
)}
<View style={styles.avatarBadge}>
<Ionicons name="camera" size={14} color={colors.onDark} />
</View>
</Pressable>
<Text style={styles.avatarHint}>{t('editProfile.photo')}</Text>
<View style={{ flexDirection: 'row', gap: spacing[3] }}>
<Field label={t('register.firstName')} style={{ flex: 1 }}>
<TextField value={firstName} onChangeText={setFirstName} autoCapitalize="words" />
</Field>
<Field label={t('register.lastName')} style={{ flex: 1 }}>
<TextField value={lastName} onChangeText={setLastName} autoCapitalize="words" />
</Field>
</View>
<Field label={t('login.email')}>
<TextField value={user?.email ?? ''} editable={false} style={{ color: colors.textMuted }} />
<Text style={styles.hint}>{t('editProfile.emailReadOnly')}</Text>
</Field>
<Field label={t('editProfile.phone')} optional>
<TextField
icon="call-outline"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
textContentType="telephoneNumber"
/>
</Field>
<Field label={t('editProfile.address')} optional>
<TextField
icon="location-outline"
value={address}
onChangeText={setAddress}
autoCapitalize="words"
/>
</Field>
<Button
title={t('editProfile.save')}
icon="checkmark"
size="lg"
block
loading={save.isPending}
onPress={() => save.mutate()}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
body: { padding: spacing[4], paddingBottom: spacing[9] },
avatarWrap: { alignSelf: 'center', marginBottom: spacing[2] },
avatarImage: { width: 92, height: 92, borderRadius: 46, backgroundColor: colors.surfaceSunken },
avatarBadge: {
position: 'absolute',
right: -2,
bottom: -2,
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: colors.teal600,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.bgApp,
},
avatarHint: {
textAlign: 'center',
fontSize: fontSize.sm,
fontWeight: '600',
color: colors.teal700,
marginBottom: spacing[6],
},
hint: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 6 },
});
+99
View File
@@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text } from 'react-native';
import { useRouter } from 'expo-router';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Button, Field, TextField } from '@/components/ui';
import { colors, fontSize, spacing } from '@/theme';
const MIN_PASSWORD = 8;
export default function ChangePasswordScreen() {
const router = useRouter();
const t = useT();
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [show, setShow] = useState(false);
const change = useMutation({
mutationFn: () => api.auth.changePassword(current, next),
onSuccess: () => {
// The backend keeps this device signed in and drops the others.
Alert.alert(t('changePassword.doneTitle'), t('changePassword.doneBody'), [
{ text: 'OK', onPress: () => router.back() },
]);
},
onError: (e: any) => {
const wrong = e?.fields?.current_password?.length;
Alert.alert(
t('changePassword.failed'),
wrong ? t('changePassword.wrongCurrent') : (e?.message ?? t('common.tryAgain')),
);
},
});
function onSubmit() {
if (!current) return Alert.alert(t('changePassword.failed'), t('changePassword.wrongCurrent'));
if (next.length < MIN_PASSWORD) {
return Alert.alert(
t('changePassword.failed'),
t('register.shortPassword', { count: MIN_PASSWORD }),
);
}
change.mutate();
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bgApp }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<AppBar title={t('changePassword.title')} back />
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
<Field label={t('changePassword.current')}>
<TextField
icon="lock-closed-outline"
value={current}
onChangeText={setCurrent}
secureTextEntry={!show}
autoCapitalize="none"
textContentType="password"
/>
</Field>
<Field label={t('changePassword.next')}>
<TextField
icon="key-outline"
value={next}
onChangeText={setNext}
placeholder={t('register.passwordHint', { count: MIN_PASSWORD })}
secureTextEntry={!show}
autoCapitalize="none"
textContentType="newPassword"
/>
<Pressable onPress={() => setShow((v) => !v)} style={styles.showPass} hitSlop={8}>
<Text style={styles.link}>{show ? t('login.hide') : t('login.show')}</Text>
</Pressable>
</Field>
<Button
title={t('changePassword.submit')}
icon="checkmark"
size="lg"
block
loading={change.isPending}
onPress={onSubmit}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
body: { padding: spacing[4], paddingBottom: spacing[9] },
showPass: { position: 'absolute', right: 14, top: 40 },
link: { color: colors.teal700, fontWeight: '600', fontSize: fontSize.sm },
});
+227
View File
@@ -0,0 +1,227 @@
import React, { useMemo } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { api, DocCategory, Document } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Button, Card, IconButton, Loading, SectionTitle } from '@/components/ui';
import { DocumentRow } from '@/components/domain';
import { PropertyCover } from '@/components/PropertyCover';
import { categoryColor, colors, fontSize, radii, shadow, spacing, tint } from '@/theme';
export default function PropertyDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const propertyId = Number(id);
const router = useRouter();
const t = useT();
const propertyQuery = useQuery({
queryKey: ['property', propertyId],
queryFn: () => api.properties.get(propertyId),
});
const docsQuery = useQuery({
queryKey: ['documents', propertyId],
queryFn: () => api.documents.listByProperty(propertyId),
});
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
const docs: Document[] = docsQuery.data ?? [];
const categories: DocCategory[] = catsQuery.data ?? [];
const catName = (slug: string) => categories.find((c) => c.slug === slug)?.name ?? slug;
const folders = useMemo(() => {
const map = new Map<string, { count: number; expiring: number }>();
for (const d of docs) {
const entry = map.get(d.category) ?? { count: 0, expiring: 0 };
entry.count += 1;
if (d.expiryStatus === 'expiring' || d.expiryStatus === 'expired') entry.expiring += 1;
map.set(d.category, entry);
}
return Array.from(map.entries()).map(([slug, v]) => ({ slug, ...v }));
}, [docs]);
const expiringDocs = docs.filter((d) => d.expiryStatus === 'expiring' || d.expiryStatus === 'expired');
const recent = [...docs]
.sort((a, b) => (b.uploadedAt > a.uploadedAt ? 1 : -1))
.slice(0, 3);
const property = propertyQuery.data;
const photoMissing = !property?.photoUrl;
const addressLine = [property?.address, property?.zip, property?.city].filter(Boolean).join(', ');
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar
title={property?.name ?? t('property.fallbackTitle')}
back
right={
<IconButton
name="create-outline"
onPress={() => router.push(`/property/edit/${propertyId}`)}
/>
}
/>
{propertyQuery.isLoading ? (
<Loading />
) : (
<ScrollView contentContainerStyle={styles.body}>
{/* Tapping an empty cover is the shortest path to adding a photo. */}
<Pressable
onPress={photoMissing ? () => router.push(`/property/edit/${propertyId}`) : undefined}
style={styles.coverWrap}
>
<PropertyCover photoUrl={property?.photoUrl} type={property?.type} height={150} />
</Pressable>
<Text style={styles.name}>{property?.name}</Text>
{addressLine ? (
<View style={styles.addrRow}>
<Ionicons name="location-outline" size={15} color={colors.slate500} />
<Text style={styles.addr}>{addressLine}</Text>
</View>
) : (
// Missing data is an invitation to fill it in, not a dash.
<Pressable
style={styles.addrRow}
onPress={() => router.push(`/property/edit/${propertyId}`)}
>
<Ionicons name="add-circle-outline" size={15} color={colors.teal600} />
<Text style={styles.addrAdd}>{t('property.addAddress')}</Text>
</Pressable>
)}
<Button
title={t('property.addDocument')}
icon="cloud-upload-outline"
block
onPress={() => router.push(`/upload?propertyId=${propertyId}`)}
style={{ marginTop: spacing[5] }}
/>
{expiringDocs.length > 0 ? (
<Card
style={styles.warnBanner}
// One document opens directly; several open the filtered list.
onPress={() =>
expiringDocs.length === 1
? router.push(`/document/${expiringDocs[0].id}?propertyId=${propertyId}`)
: router.push(`/documents/${propertyId}?status=attention`)
}
>
<Ionicons name="time-outline" size={20} color={colors.warningText} />
<View style={{ flex: 1 }}>
<Text style={styles.warnTitle}>
{t('property.needAttention', { count: expiringDocs.length })}
</Text>
<Text style={styles.warnText} numberOfLines={2}>
{expiringDocs.map((d) => d.name).join(' · ')}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.warningText} />
</Card>
) : null}
<SectionTitle title={t('property.categories')} action={<Text style={styles.link}>{t('property.folders', { count: folders.length })}</Text>} />
{folders.length === 0 ? (
<Card>
<Text style={styles.emptyFolders}>{t('property.noDocuments')}</Text>
</Card>
) : (
<View style={styles.folderGrid}>
{folders.map((f) => (
<Pressable
key={f.slug}
style={styles.folder}
onPress={() => router.push(`/documents/${propertyId}?category=${f.slug}`)}
>
{/* Accent bar carries the category colour, as in the deck. */}
<View style={[styles.folderAccent, { backgroundColor: categoryColor(f.slug) }]} />
<View style={[styles.folderIcon, { backgroundColor: tint(categoryColor(f.slug)) }]}>
<Ionicons name="folder" size={20} color={categoryColor(f.slug)} />
</View>
<Text style={styles.folderName}>{catName(f.slug)}</Text>
<Text style={[styles.folderCount, f.expiring > 0 && { color: colors.warningText, fontWeight: '600' }]}>
{t('properties.docCount', { count: f.count })}
{f.expiring > 0 ? ` · ${t('properties.expiringCount', { count: f.expiring })}` : ''}
</Text>
</Pressable>
))}
</View>
)}
<SectionTitle
title={t('property.recent')}
action={
docs.length > 0 ? (
<Pressable onPress={() => router.push(`/documents/${propertyId}`)}>
<Text style={styles.link}>{t('common.seeAll')}</Text>
</Pressable>
) : undefined
}
/>
{recent.length === 0 ? (
<Card>
<Text style={styles.emptyFolders}>{t('property.noRecent')}</Text>
</Card>
) : (
<View style={{ gap: spacing[3] }}>
{recent.map((d: Document) => (
<DocumentRow
key={d.id}
document={d}
onPress={() => router.push(`/document/${d.id}?propertyId=${propertyId}`)}
/>
))}
</View>
)}
</ScrollView>
)}
</View>
);
}
const styles = StyleSheet.create({
body: { padding: spacing[4], paddingBottom: spacing[9] },
coverWrap: { borderRadius: radii.lg, overflow: 'hidden', marginBottom: spacing[4] },
name: { fontSize: fontSize['2xl'], fontWeight: '700', color: colors.ink },
addrRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 4 },
addr: { fontSize: fontSize.base, color: colors.slate600, flex: 1 },
addrAdd: { fontSize: fontSize.base, color: colors.teal700, fontWeight: '600' },
warnBanner: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing[3],
backgroundColor: colors.warningBg,
borderColor: '#f0dcae',
marginTop: spacing[5],
},
warnTitle: { fontSize: fontSize.md, fontWeight: '700', color: colors.warningText },
warnText: { fontSize: fontSize.sm, color: colors.warningText, opacity: 0.85, marginTop: 2 },
link: { color: colors.teal700, fontWeight: '600', fontSize: fontSize.sm },
folderGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[3] },
folder: {
width: '47.8%',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
padding: spacing[4],
overflow: 'hidden',
...shadow.xs,
},
folderAccent: { position: 'absolute', left: 0, top: 0, bottom: 0, width: 4 },
folderIcon: {
width: 40,
height: 40,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10,
},
folderName: { fontSize: fontSize.base, fontWeight: '600', color: colors.ink },
folderCount: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 3 },
emptyFolders: { fontSize: fontSize.sm, color: colors.textMuted, textAlign: 'center', paddingVertical: spacing[3] },
});
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { PropertyForm } from '@/components/PropertyForm';
export default function AddPropertyScreen() {
return <PropertyForm />;
}
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
import { useLocalSearchParams } from 'expo-router';
import { PropertyForm } from '@/components/PropertyForm';
export default function EditPropertyScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return <PropertyForm propertyId={Number(id)} />;
}
+279
View File
@@ -0,0 +1,279 @@
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
Linking,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import MapView, { Marker, PROVIDER_GOOGLE, Region } from 'react-native-maps';
import * as Location from 'expo-location';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Button } from '@/components/ui';
import {
ensureGeocodePermission,
ensureLocationPermission,
openPermissionSettings,
} from '@/lib/permissions';
import { setPendingLocation } from '@/lib/pendingLocation';
import { colors, fontSize, radii, shadow, spacing } from '@/theme';
/** Fallback view when the property has no pin yet: Milan. */
const DEFAULT_REGION: Region = {
latitude: 45.4642,
longitude: 9.19,
latitudeDelta: 0.08,
longitudeDelta: 0.08,
};
export default function LocationPickerScreen() {
const router = useRouter();
const t = useT();
const params = useLocalSearchParams<{ lat?: string; lon?: string; address?: string }>();
const mapRef = useRef<MapView | null>(null);
const initial =
params.lat && params.lon
? { latitude: Number(params.lat), longitude: Number(params.lon) }
: null;
const [pin, setPin] = useState<{ latitude: number; longitude: number } | null>(initial);
const [label, setLabel] = useState<string | null>(null);
const [place, setPlace] = useState<Location.LocationGeocodedAddress | null>(null);
const [busy, setBusy] = useState<'gps' | 'address' | null>(null);
const region: Region = pin
? { ...pin, latitudeDelta: 0.01, longitudeDelta: 0.01 }
: DEFAULT_REGION;
// With no pin yet, derive one from the address already typed in the form.
// Silent: runs only if permission is already granted.
useEffect(() => {
if (pin || !params.address) return;
(async () => {
if (await ensureGeocodePermission(false)) {
void locateFromAddress(params.address as string, true);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!pin) return;
let cancelled = false;
(async () => {
try {
// The label is a nicety, so stay silent without permission.
if (!(await ensureGeocodePermission(false))) return;
const [found] = await Location.reverseGeocodeAsync(pin);
if (cancelled || !found) return;
setPlace(found);
setLabel(
[found.street, found.streetNumber, found.postalCode, found.city]
.filter(Boolean)
.join(', ') || null,
);
} catch {
// reverse geocoding is a nicety, never a blocker
}
})();
return () => {
cancelled = true;
};
}, [pin?.latitude, pin?.longitude]);
function moveTo(next: { latitude: number; longitude: number }) {
setPin(next);
setPlace(null); // stale until the new point is reverse-geocoded
setLabel(null);
mapRef.current?.animateToRegion({ ...next, latitudeDelta: 0.01, longitudeDelta: 0.01 }, 400);
}
async function locateFromAddress(address: string, silent = false) {
// User-initiated, so prompting here is expected.
if (!(await ensureGeocodePermission(!silent))) {
if (!silent) openPermissionSettings('permissions.locationBody');
return;
}
setBusy('address');
try {
const [hit] = await Location.geocodeAsync(address);
if (hit) moveTo({ latitude: hit.latitude, longitude: hit.longitude });
else if (!silent) Alert.alert(t('location.notFound'), t('location.notFoundBody'));
} catch (e: any) {
if (!silent) Alert.alert(t('location.notFound'), e?.message ?? t('common.tryAgain'));
} finally {
setBusy(null);
}
}
async function useCurrentPosition() {
setBusy('gps');
try {
// With location off device-wide, iOS rejects the request without a
// dialog and the app has no row in its own settings — send them elsewhere.
if (!(await Location.hasServicesEnabledAsync())) {
Alert.alert(t('location.servicesOff'), t('location.servicesOffBody'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('permissions.openSettings'), onPress: () => void Linking.openSettings() },
]);
return;
}
if (!(await ensureLocationPermission(true))) {
openPermissionSettings('permissions.locationBody');
return;
}
// A cold GPS fix can hang indefinitely; fall back to the last known one.
const pos =
(await Promise.race([
Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 12000)),
])) ?? (await Location.getLastKnownPositionAsync());
if (!pos) {
Alert.alert(t('location.gpsFailed'), t('common.tryAgain'));
return;
}
moveTo({ latitude: pos.coords.latitude, longitude: pos.coords.longitude });
} catch (e: any) {
Alert.alert(t('location.gpsFailed'), e?.message ?? t('common.tryAgain'));
} finally {
setBusy(null);
}
}
function confirm() {
if (!pin) return;
// Street and city travel back too, so the form can offer to fill them in.
setPendingLocation({
...pin,
address: place
? [place.street, place.streetNumber].filter(Boolean).join(' ') || place.name || null
: null,
postalCode: place?.postalCode ?? null,
city: place?.city ?? null,
country: place?.country ?? null,
});
router.back();
}
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar title={t('location.title')} back />
<View style={styles.mapWrap}>
<MapView
ref={mapRef}
// Google on Android needs the API key in app.json; iOS uses Apple Maps.
provider={Platform.OS === 'android' ? PROVIDER_GOOGLE : undefined}
style={StyleSheet.absoluteFill}
initialRegion={region}
onPress={(e) => setPin(e.nativeEvent.coordinate)}
>
{pin ? (
<Marker
coordinate={pin}
draggable
onDragEnd={(e) => setPin(e.nativeEvent.coordinate)}
pinColor={colors.teal600}
/>
) : null}
</MapView>
{!pin ? (
<View style={styles.hintOverlay} pointerEvents="none">
<Text style={styles.hintText}>{t('location.tapHint')}</Text>
</View>
) : null}
<Pressable style={styles.gpsButton} onPress={useCurrentPosition}>
{busy === 'gps' ? (
<ActivityIndicator color={colors.teal700} />
) : (
<Ionicons name="locate" size={20} color={colors.teal700} />
)}
</Pressable>
</View>
<View style={styles.panel}>
<View style={styles.readout}>
<Ionicons name="pin" size={16} color={colors.teal600} />
<View style={{ flex: 1 }}>
<Text style={styles.coords}>
{pin
? `${pin.latitude.toFixed(6)}, ${pin.longitude.toFixed(6)}`
: t('location.noPin')}
</Text>
{label ? (
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
) : null}
</View>
</View>
{params.address ? (
<Button
title={t('location.fromAddress')}
variant="ghost"
icon="search"
block
loading={busy === 'address'}
onPress={() => locateFromAddress(params.address as string)}
style={{ marginBottom: spacing[3] }}
/>
) : null}
<Button
title={t('location.confirm')}
icon="checkmark"
size="lg"
block
disabled={!pin}
onPress={confirm}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
mapWrap: { flex: 1, overflow: 'hidden' },
hintOverlay: {
position: 'absolute',
top: spacing[4],
alignSelf: 'center',
backgroundColor: 'rgba(18,27,36,0.78)',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: radii.pill,
},
hintText: { color: colors.onDark, fontSize: fontSize.sm, fontWeight: '600' },
gpsButton: {
position: 'absolute',
right: spacing[4],
bottom: spacing[4],
width: 46,
height: 46,
borderRadius: 23,
backgroundColor: colors.surface,
alignItems: 'center',
justifyContent: 'center',
...shadow.md,
},
panel: {
backgroundColor: colors.surface,
borderTopWidth: 1,
borderTopColor: colors.border,
padding: spacing[4],
paddingBottom: spacing[6],
},
readout: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: spacing[4] },
coords: { fontSize: fontSize.md, fontWeight: '600', color: colors.ink },
label: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: 2 },
});
+422
View File
@@ -0,0 +1,422 @@
import React, { useEffect, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as DocumentPicker from 'expo-document-picker';
import { api, DocCategory, DocRequirement, LocalFile, Property } from '@/api';
import { useT } from '@/i18n';
import { Button, Field, IconButton, Label, TextField } from '@/components/ui';
import { DateField } from '@/components/DateField';
import { FileGlyph } from '@/components/domain';
import { pickImageFromLibrary } from '@/lib/pickImage';
import { isCameraBlocked, openPermissionSettings, primeDocumentPermissions } from '@/lib/permissions';
import { scanDocumentToPdf } from '@/scanner/scanToPdf';
import { formatBytes } from '@/lib/format';
import { categoryColor, colors, fontSize, radii, spacing } from '@/theme';
export default function UploadScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const qc = useQueryClient();
const t = useT();
const params = useLocalSearchParams<{ propertyId?: string }>();
const propertiesQuery = useQuery({ queryKey: ['properties'], queryFn: () => api.properties.list() });
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
const properties: Property[] = propertiesQuery.data ?? [];
const categories: DocCategory[] = categoriesQuery.data ?? [];
const [file, setFile] = useState<LocalFile | null>(null);
const [name, setName] = useState('');
const [propertyId, setPropertyId] = useState<number | null>(params.propertyId ? Number(params.propertyId) : null);
const [category, setCategory] = useState<string>('');
const [requirementId, setRequirementId] = useState<number | null>(null);
const [pickingRequirement, setPickingRequirement] = useState(false);
const [expiry, setExpiry] = useState<string | null>(null);
const [pickingProperty, setPickingProperty] = useState(false);
const [scanning, setScanning] = useState(false);
const effectivePropertyId = propertyId ?? properties[0]?.id ?? null;
const effectiveCategory = category || categories[0]?.slug || '';
// Files hang off a requirement, so they load per category and are picked.
const requirementsQuery = useQuery({
queryKey: ['requirements', effectiveCategory],
queryFn: () => api.reference.requirements(effectiveCategory),
enabled: !!effectiveCategory,
});
const requirements: DocRequirement[] = requirementsQuery.data ?? [];
const selectedRequirement = requirements.find((r) => r.id === requirementId) ?? null;
const selectedProperty = properties.find((p) => p.id === effectivePropertyId);
const uploadMutation = useMutation({
mutationFn: () =>
api.documents.upload({
propertyId: effectivePropertyId as number,
name: name.trim(),
category: effectiveCategory,
requirementId: requirementId ?? undefined,
expiryDate: expiry,
file: file as LocalFile,
}),
onSuccess: () => {
// Fire-and-forget: the lists update as the refetches land, and the modal
// should not wait on them.
qc.invalidateQueries({ queryKey: ['documents'] });
qc.invalidateQueries({ queryKey: ['all-documents'] });
qc.invalidateQueries({ queryKey: ['properties'] });
router.back();
},
onError: (e: any) => Alert.alert(t('upload.failed'), e?.message ?? t('common.tryAgain')),
});
function applyFile(f: LocalFile, suggestedName?: string) {
setFile(f);
if (!name && suggestedName) setName(suggestedName);
}
async function onChooseFile() {
const res = await DocumentPicker.getDocumentAsync({
type: ['application/pdf', 'image/*'],
copyToCacheDirectory: true,
});
if (res.canceled || !res.assets?.length) return;
const a = res.assets[0];
applyFile(
{ uri: a.uri, name: a.name, mimeType: a.mimeType ?? 'application/octet-stream', sizeBytes: a.size ?? 0 },
a.name.replace(/\.[^.]+$/, ''),
);
}
async function onPickPhoto() {
const picked = await pickImageFromLibrary({ quality: 0.9, fallbackName: 'photo' });
if (picked) applyFile(picked, t('upload.photoName'));
}
async function onScan() {
setScanning(true);
try {
const result = await scanDocumentToPdf(name || t('upload.scannedName'));
if (result) {
applyFile(result.file, t('upload.scannedName'));
}
} catch (e: any) {
// A blocked camera can't be fixed by retrying.
if (await isCameraBlocked()) openPermissionSettings('permissions.cameraBody');
else Alert.alert(t('upload.scannerUnavailable'), e?.message ?? t('upload.scannerUnavailableBody'));
} finally {
setScanning(false);
}
}
// Asked on open, so nobody is stopped halfway through picking a source.
useEffect(() => {
void primeDocumentPermissions();
}, []);
function onSubmit() {
if (!file) return Alert.alert(t('upload.noFileTitle'), t('upload.noFileBody'));
if (!name.trim()) return Alert.alert(t('upload.nameRequired'), t('upload.nameRequiredBody'));
if (!effectivePropertyId) return Alert.alert(t('upload.noPropertyTitle'), t('upload.noPropertyBody'));
// Guessing the requirement files the document in the wrong place.
if (requirements.length > 0 && !requirementId) {
return Alert.alert(t('upload.noRequirementTitle'), t('upload.noRequirementBody'));
}
uploadMutation.mutate();
}
return (
<View style={{ flex: 1, backgroundColor: colors.surface }}>
<View style={[styles.header, { paddingTop: insets.top + spacing[3] }]}>
<Text style={styles.headerTitle}>{t('upload.title')}</Text>
<IconButton name="close" onPress={() => router.back()} />
</View>
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{/* Source */}
<Label>{t('upload.source')}</Label>
<View style={styles.sourceRow}>
<SourceButton icon="scan-outline" label={t('upload.scan')} loadingLabel={t('upload.scanning')} loading={scanning} onPress={onScan} />
<SourceButton icon="document-outline" label={t('upload.chooseFile')} onPress={onChooseFile} />
<SourceButton icon="image-outline" label={t('upload.gallery')} onPress={onPickPhoto} />
</View>
<Text style={styles.scanHint}>
{t('upload.scanHint')}
</Text>
{/* Selected file */}
{file ? (
<View style={styles.fileCard}>
<FileGlyph mimeType={file.mimeType} />
<View style={{ flex: 1, marginLeft: spacing[3] }}>
<Text style={styles.fileName} numberOfLines={1}>
{file.name}
</Text>
<Text style={styles.fileMeta}>{formatBytes(file.sizeBytes)} · {t('upload.readyToUpload')}</Text>
</View>
<Pressable onPress={() => setFile(null)} hitSlop={8}>
<Ionicons name="close-circle" size={22} color={colors.slate400} />
</Pressable>
</View>
) : null}
{/* Name */}
<Field label={t('upload.name')} style={{ marginTop: spacing[5] }}>
<TextField value={name} onChangeText={setName} placeholder={t('upload.namePlaceholder')} />
</Field>
{/* Property */}
<Field label={t('upload.property')}>
<Pressable style={styles.select} onPress={() => setPickingProperty((v) => !v)}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Ionicons name="home-outline" size={18} color={colors.teal600} />
<Text style={styles.selectValue}>{selectedProperty?.name ?? t('upload.selectProperty')}</Text>
</View>
<Ionicons name={pickingProperty ? 'chevron-up' : 'chevron-down'} size={18} color={colors.slate400} />
</Pressable>
{pickingProperty ? (
<View style={styles.selectList}>
{properties.map((p) => (
<Pressable
key={p.id}
style={styles.selectItem}
onPress={() => {
setPropertyId(p.id);
setPickingProperty(false);
}}
>
<Text style={styles.selectItemText}>{p.name}</Text>
{p.id === effectivePropertyId ? (
<Ionicons name="checkmark" size={18} color={colors.teal600} />
) : null}
</Pressable>
))}
</View>
) : null}
</Field>
{/* Category */}
<Field label={t('upload.category')}>
<View style={styles.catWrap}>
{categories.map((c) => {
const active = c.slug === effectiveCategory;
return (
<Pressable
key={c.slug}
onPress={() => {
setCategory(c.slug);
// Requirements belong to a category, so the old pick is stale.
setRequirementId(null);
setPickingRequirement(false);
}}
style={[styles.catChip, active && { backgroundColor: categoryColor(c.slug) + '18', borderColor: categoryColor(c.slug) }]}
>
<View style={[styles.catDot, { backgroundColor: categoryColor(c.slug) }]} />
<Text style={[styles.catText, active && { color: categoryColor(c.slug) }]}>{c.name}</Text>
</Pressable>
);
})}
</View>
</Field>
{/* Requirement — the backend's real filing unit */}
{requirements.length > 0 ? (
<Field label={t('upload.requirement')}>
<Pressable style={styles.select} onPress={() => setPickingRequirement((v) => !v)}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }}>
<Ionicons name="pricetag-outline" size={18} color={colors.teal600} />
<Text
style={[styles.selectValue, !selectedRequirement && { color: colors.textMuted2, fontWeight: '400' }]}
numberOfLines={1}
>
{selectedRequirement?.name ?? t('upload.selectRequirement')}
</Text>
</View>
<Ionicons
name={pickingRequirement ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.slate400}
/>
</Pressable>
{pickingRequirement ? (
<View style={styles.selectList}>
{requirements.map((r, i) => {
const newSection = i === 0 || requirements[i - 1].sectionName !== r.sectionName;
return (
<React.Fragment key={r.id}>
{newSection && r.sectionName ? (
<Text style={styles.sectionHeader}>{r.sectionName}</Text>
) : null}
<Pressable
style={styles.selectItem}
onPress={() => {
setRequirementId(r.id);
setPickingRequirement(false);
// The requirement names it, unless the user already did.
if (!name.trim()) setName(r.name);
}}
>
<Text style={styles.selectItemText} numberOfLines={2}>
{r.name}
</Text>
{r.id === requirementId ? (
<Ionicons name="checkmark" size={18} color={colors.teal600} />
) : null}
</Pressable>
</React.Fragment>
);
})}
</View>
) : null}
<Text style={styles.requirementHint}>{t('upload.requirementHint')}</Text>
</Field>
) : null}
{/* Expiry */}
<Field label={t('upload.expiry')} optional>
<DateField value={expiry} onChange={setExpiry} minimumDate={new Date()} />
</Field>
<Button
title={t('upload.submit')}
icon="cloud-upload-outline"
size="lg"
block
loading={uploadMutation.isPending}
onPress={onSubmit}
style={{ marginTop: spacing[3], marginBottom: insets.bottom + spacing[5] }}
/>
</ScrollView>
</View>
);
}
function SourceButton({
icon,
label,
loadingLabel,
onPress,
loading,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string;
loadingLabel?: string;
onPress: () => void;
loading?: boolean;
}) {
return (
<Pressable
onPress={loading ? undefined : onPress}
style={({ pressed }) => [styles.sourceBtn, pressed && { opacity: 0.85 }]}
>
<View style={styles.sourceIcon}>
<Ionicons name={icon} size={22} color={colors.teal600} />
</View>
<Text style={styles.sourceLabel}>
{loading ? loadingLabel ?? label : label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing[4],
paddingBottom: spacing[3],
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
headerTitle: { fontSize: fontSize.lg, fontWeight: '700', color: colors.ink },
body: { padding: spacing[4] },
sourceRow: { flexDirection: 'row', gap: spacing[3], marginTop: spacing[2] },
sourceBtn: {
flex: 1,
alignItems: 'center',
gap: 8,
paddingVertical: spacing[5],
borderRadius: radii.lg,
borderWidth: 1.5,
borderColor: colors.border,
borderStyle: 'dashed',
backgroundColor: colors.surface,
},
sourceIcon: {
width: 42,
height: 42,
borderRadius: 12,
backgroundColor: colors.teal50,
alignItems: 'center',
justifyContent: 'center',
},
sourceLabel: { fontSize: fontSize.sm, fontWeight: '600', color: colors.ink },
scanHint: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: spacing[3], textAlign: 'center' },
fileCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surfaceSunken,
borderRadius: radii.md,
padding: spacing[3],
marginTop: spacing[5],
},
fileName: { fontSize: fontSize.sm, fontWeight: '600', color: colors.ink },
fileMeta: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 2 },
select: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
paddingHorizontal: spacing[4],
minHeight: 50,
},
selectValue: { fontSize: fontSize.md, fontWeight: '600', color: colors.ink },
selectList: {
marginTop: 6,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
overflow: 'hidden',
},
selectItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing[4],
paddingVertical: spacing[3],
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
selectItemText: { fontSize: fontSize.base, color: colors.ink, flex: 1, marginRight: spacing[2] },
sectionHeader: {
fontSize: fontSize['2xs'],
fontWeight: '700',
color: colors.slate500,
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: spacing[4],
paddingTop: spacing[3],
paddingBottom: 4,
backgroundColor: colors.surfaceSunken,
},
requirementHint: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 6 },
catWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
catChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 7,
paddingHorizontal: 12,
height: 36,
borderRadius: radii.pill,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
},
catDot: { width: 8, height: 8, borderRadius: 4 },
catText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.slate600 },
});