initial
This commit is contained in:
@@ -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 } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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] },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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] },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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] },
|
||||
});
|
||||
@@ -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] },
|
||||
});
|
||||
@@ -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)" />;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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] },
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { PropertyForm } from '@/components/PropertyForm';
|
||||
|
||||
export default function AddPropertyScreen() {
|
||||
return <PropertyForm />;
|
||||
}
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user