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

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+10
View File
@@ -0,0 +1,10 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
// expo-router requires this; reanimated plugin must stay last.
'react-native-reanimated/plugin',
],
};
};
+12214
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
{
"name": "casadoc",
"version": "0.1.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild --clean",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"reverse": "adb reverse tcp:8081 tcp:8081 && adb reverse tcp:8080 tcp:8080",
"postinstall": "patch-package"
},
"dependencies": {
"@expo/vector-icons": "~14.0.4",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/datetimepicker": "8.2.0",
"@react-native-community/netinfo": "11.4.1",
"@react-navigation/native": "^7.0.0",
"@tanstack/query-async-storage-persister": "^5.101.4",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-persist-client": "^5.101.4",
"expo": "~52.0.0",
"expo-asset": "~11.0.5",
"expo-build-properties": "~0.13.3",
"expo-constants": "~17.0.0",
"expo-document-picker": "~13.0.1",
"expo-file-system": "~18.0.4",
"expo-font": "~13.0.4",
"expo-image-picker": "~16.0.3",
"expo-intent-launcher": "~12.0.2",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.3",
"expo-localization": "~16.0.1",
"expo-location": "~18.0.10",
"expo-print": "~14.0.2",
"expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0",
"expo-sharing": "~13.0.1",
"expo-splash-screen": "~0.29.13",
"expo-status-bar": "~2.0.0",
"expo-system-ui": "~4.0.4",
"expo-web-browser": "~14.0.1",
"i18n-js": "^4.5.3",
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-document-scanner-plugin": "^2.0.4",
"react-native-gesture-handler": "~2.20.2",
"react-native-maps": "1.18.0",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0"
},
"devDependencies": {
"@types/react": "~18.3.12",
"patch-package": "^8.0.1",
"postinstall-postinstall": "^2.1.0",
"typescript": "~5.3.3"
},
"private": true
}
+13
View File
@@ -0,0 +1,13 @@
diff --git a/node_modules/expo-localization/ios/LocalizationModule.swift b/node_modules/expo-localization/ios/LocalizationModule.swift
index 73b2b9d..ab98d69 100644
--- a/node_modules/expo-localization/ios/LocalizationModule.swift
+++ b/node_modules/expo-localization/ios/LocalizationModule.swift
@@ -123,6 +123,8 @@ public class LocalizationModule: Module {
return "roc"
case .iso8601:
return "iso8601"
+ @unknown default:
+ return "gregory"
}
}
+50
View File
@@ -0,0 +1,50 @@
import Constants from 'expo-constants';
/** True runs the app on in-memory demo data, with no server. */
export const USE_MOCK = false;
/** From `expo.extra.apiUrl` in app.json. Must be https for a release build. */
const PRODUCTION_URL: string =
(Constants.expoConfig?.extra as { apiUrl?: string } | undefined)?.apiUrl ?? '';
/** Port the local Docker backend publishes on. */
const DEV_PORT = 8080;
/**
* LAN address of the development machine. Assigned by DHCP, so update it with
* `ipconfig getifaddr en0` if the backend stops responding, or set it to '' to
* auto-detect from the Metro host.
*/
const DEV_HOST = '192.168.88.82';
/** Used only when DEV_HOST is empty: Metro's host doubles as the API host. */
function devBaseUrl(): string | null {
const hostUri =
Constants.expoConfig?.hostUri ??
(Constants as any).expoGoConfig?.debuggerHost ??
(Constants as any).manifest2?.extra?.expoGo?.debuggerHost;
const host = typeof hostUri === 'string' ? hostUri.split(':')[0] : null;
if (!host) return null;
// Used verbatim: over USB it is `localhost`, which `npm run reverse` forwards
// to the backend — that covers emulators too, so no 10.0.2.2 special case.
return `http://${host}:${DEV_PORT}/userportal/api`;
}
export const BASE_URL = __DEV__
? DEV_HOST
? `http://${DEV_HOST}:${DEV_PORT}/userportal/api`
: (devBaseUrl() ?? PRODUCTION_URL)
: PRODUCTION_URL;
// A binary that talks to a placeholder host is an automatic store rejection.
if (!__DEV__ && (!BASE_URL || BASE_URL.includes('REPLACE-ME'))) {
throw new Error(
'expo.extra.apiUrl in app.json must point at the production API before building a release.',
);
}
/** Sent as `device_name` on login (see /login.php). */
export const DEVICE_NAME = 'CasaDoc Mobile';
/** Network timeout (ms) for real requests. */
export const REQUEST_TIMEOUT = 20000;
+118
View File
@@ -0,0 +1,118 @@
import { BASE_URL, REQUEST_TIMEOUT } from './config';
// Fetch wrapper: bearer auth, JSON in and out, one error shape, multipart
// upload. The token is set by the auth layer after login or restore.
let authToken: string | null = null;
export function setAuthToken(token: string | null) {
authToken = token;
}
export class ApiError extends Error {
status: number;
code: string;
fields?: Record<string, string[]> | null;
constructor(status: number, code: string, message: string, fields?: Record<string, string[]> | null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.fields = fields;
}
}
function authHeaders(): Record<string, string> {
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
}
async function withTimeout<T>(run: (signal: AbortSignal) => Promise<T>): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
try {
return await run(controller.signal);
} finally {
clearTimeout(timer);
}
}
async function parse(res: Response): Promise<any> {
const text = await res.text();
const body = text ? JSON.parse(text) : {};
if (!res.ok) {
const err = body?.error ?? {};
throw new ApiError(
res.status,
err.code ?? String(res.status),
err.message ?? res.statusText ?? 'Request failed',
err.fields ?? null,
);
}
return body;
}
/** GET `path` (relative to BASE_URL) with optional query params. */
export async function get<T = any>(path: string, query?: Record<string, unknown>): Promise<T> {
const qs = query
? '?' +
Object.entries(query)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&')
: '';
return withTimeout(async (signal) => {
const res = await fetch(`${BASE_URL}${path}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json', ...authHeaders() },
signal,
});
return parse(res);
});
}
/** POST JSON body. */
export async function postJson<T = any>(path: string, body: unknown): Promise<T> {
return withTimeout(async (signal) => {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...authHeaders(),
},
body: JSON.stringify(body ?? {}),
signal,
});
return parse(res);
});
}
/** POST multipart/form-data (file uploads). Values may be strings or RN file parts. */
export async function postForm<T = any>(path: string, form: FormData): Promise<T> {
return withTimeout(async (signal) => {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
// No Content-Type: RN sets the multipart boundary itself.
headers: { Accept: 'application/json', ...authHeaders() },
body: form as any,
signal,
});
return parse(res);
});
}
/** Absolute URL for a binary file endpoint (used by viewer / download). */
export function fileUrl(path: string, query?: Record<string, unknown>): string {
const qs = query
? '?' +
Object.entries(query)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&')
: '';
return `${BASE_URL}${path}${qs}`;
}
export function getAuthToken(): string | null {
return authToken;
}
+11
View File
@@ -0,0 +1,11 @@
import { USE_MOCK } from './config';
import { mockApi } from './mock';
import { realApi } from './real';
import { CasaDocApi } from './types';
/** Every screen imports this; USE_MOCK in config.ts decides which one runs. */
export const api: CasaDocApi = USE_MOCK ? mockApi : realApi;
export * from './types';
export { USE_MOCK } from './config';
export { setAuthToken, getAuthToken } from './http';
+394
View File
@@ -0,0 +1,394 @@
import {
CasaDocApi,
CreatePropertyInput,
DocCategory,
DocRequirement,
Document,
LoginResult,
ProfileInput,
Property,
RegisterInput,
LocalFile,
UpdateDocumentInput,
UpdatePropertyInput,
UploadInput,
User,
} from './types';
// In-memory backend for USE_MOCK. State lives for the app session only.
const DELAY = 380;
const wait = <T>(value: T): Promise<T> =>
new Promise((resolve) => setTimeout(() => resolve(value), DELAY));
let MOCK_USER: User = {
id: 1,
firstName: 'John',
lastName: 'Smith',
email: 'j.smith@casadoc.app',
username: 'jsmith',
avatar: null,
};
export const CATEGORIES: DocCategory[] = [
{ slug: 'ownership', name: 'Ownership' },
{ slug: 'rental', name: 'Rental' },
{ slug: 'sale', name: 'Sale' },
{ slug: 'technical', name: 'Technical' },
{ slug: 'certifications', name: 'Certifications' },
{ slug: 'warranties', name: 'Warranties' },
{ slug: 'maintenance', name: 'Maintenance' },
{ slug: 'other', name: 'Other' },
];
let properties: Property[] = [
{
id: 1,
name: 'Villa Bellavista',
type: 'villa',
address: 'Via Roma 12',
city: 'Milano',
zip: '20121',
country: 'Italy',
description: null,
photoUrl: null,
latitude: 45.4642,
longitude: 9.19,
documentCount: 34,
expiringCount: 2,
},
{
id: 2,
name: 'Apartment Navigli 4B',
type: 'apartment',
address: 'Ripa di Porta Ticinese 65',
city: 'Milano',
zip: '20143',
country: 'Italy',
description: null,
photoUrl: null,
latitude: 45.4508,
longitude: 9.1743,
documentCount: 21,
expiringCount: 0,
},
{
id: 3,
name: 'Office Corso Como 7',
type: 'commercial',
address: 'Corso Como 7',
city: 'Milano',
zip: '20154',
country: 'Italy',
description: null,
photoUrl: null,
latitude: 45.4809,
longitude: 9.1876,
documentCount: 48,
expiringCount: 1,
},
];
let documents: Document[] = [
{
id: 101,
propertyId: 1,
name: 'Energy Performance Certificate',
category: 'certifications',
mimeType: 'application/pdf',
sizeBytes: 2_400_000,
fileUri: null,
uploadedAt: '2026-06-12',
expiryDate: '2026-07-10',
expiryStatus: 'expiring',
pageCount: 3,
},
{
id: 102,
propertyId: 1,
name: 'Deed of Sale 2018',
category: 'ownership',
mimeType: 'application/pdf',
sizeBytes: 5_100_000,
fileUri: null,
uploadedAt: '2026-06-09',
expiryDate: null,
expiryStatus: 'valid',
pageCount: 12,
},
{
id: 103,
propertyId: 1,
name: 'Habitability Certificate',
category: 'certifications',
mimeType: 'application/pdf',
sizeBytes: 1_100_000,
fileUri: null,
uploadedAt: '2026-05-28',
expiryDate: '2027-05-28',
expiryStatus: 'valid',
pageCount: 2,
},
{
id: 104,
propertyId: 1,
name: 'Gas Safety Inspection',
category: 'certifications',
mimeType: 'application/pdf',
sizeBytes: 900_000,
fileUri: null,
uploadedAt: '2026-06-14',
expiryDate: '2026-06-30',
expiryStatus: 'expiring',
pageCount: 1,
},
{
id: 105,
propertyId: 1,
name: 'Electrical Conformity (CEI)',
category: 'technical',
mimeType: 'application/pdf',
sizeBytes: 1_700_000,
fileUri: null,
uploadedAt: '2026-04-03',
expiryDate: '2026-05-01',
expiryStatus: 'expired',
pageCount: 4,
},
{
id: 201,
propertyId: 2,
name: 'Rental Agreement 2026',
category: 'rental',
mimeType: 'application/pdf',
sizeBytes: 820_000,
fileUri: null,
uploadedAt: '2026-01-15',
expiryDate: '2027-01-15',
expiryStatus: 'valid',
pageCount: 6,
},
];
/** Stand-ins for the backend's document templates. */
const MOCK_REQUIREMENTS: Record<string, Omit<DocRequirement, 'id'>[]> = {
ownership: [
{ name: 'Deed of sale', sectionName: 'Title', isRequired: true, maxDocuments: 1 },
{ name: 'Land registry extract', sectionName: 'Title', isRequired: false, maxDocuments: 2 },
],
certifications: [
{ name: 'Energy Performance Certificate (EPC)', sectionName: 'Certificates', isRequired: true, maxDocuments: 1 },
{ name: 'Habitability certificate', sectionName: 'Certificates', isRequired: true, maxDocuments: 1 },
{ name: 'Gas safety inspection', sectionName: 'Inspections', isRequired: false, maxDocuments: 3 },
],
technical: [
{ name: 'Electrical conformity', sectionName: 'Systems', isRequired: true, maxDocuments: 1 },
{ name: 'Floor plans', sectionName: 'Drawings', isRequired: false, maxDocuments: 5 },
],
rental: [
{ name: 'Rental agreement', sectionName: 'Contracts', isRequired: true, maxDocuments: 1 },
],
other: [{ name: 'Other document', sectionName: null, isRequired: false, maxDocuments: 10 }],
};
/** Stable, so requirements keep the same id across calls. */
function hashSlug(slug: string): number {
let h = 1000;
for (let i = 0; i < slug.length; i += 1) h = (h * 31 + slug.charCodeAt(i)) % 100000;
return h;
}
let nextPropertyId = 100;
let nextDocId = 900;
function recomputeCounts(propertyId: number) {
const p = properties.find((x) => x.id === propertyId);
if (!p) return;
const docs = documents.filter((d) => d.propertyId === propertyId);
p.documentCount = docs.length;
p.expiringCount = docs.filter((d) => d.expiryStatus === 'expiring' || d.expiryStatus === 'expired').length;
}
function deriveExpiryStatus(expiryDate?: string | null): Document['expiryStatus'] {
if (!expiryDate) return 'valid';
const now = Date.now();
const exp = new Date(expiryDate).getTime();
if (Number.isNaN(exp)) return 'valid';
const days = (exp - now) / (1000 * 60 * 60 * 24);
if (days < 0) return 'expired';
if (days <= 60) return 'expiring';
return 'valid';
}
export const mockApi: CasaDocApi = {
auth: {
async login(login: string): Promise<LoginResult> {
return wait({ token: 'mock-token-' + login, user: MOCK_USER });
},
async me(): Promise<User> {
return wait(MOCK_USER);
},
async logout(): Promise<void> {
return wait(undefined);
},
async deleteAccount(): Promise<void> {
properties = [];
documents = [];
return wait(undefined);
},
async register(): Promise<void> {
return wait(undefined);
},
async verifyEmail(): Promise<LoginResult> {
return wait({ token: 'mock-token', user: MOCK_USER });
},
async resendVerification(): Promise<void> {
return wait(undefined);
},
async forgotPassword(): Promise<void> {
return wait(undefined);
},
async resetPassword(): Promise<void> {
return wait(undefined);
},
async changePassword(): Promise<void> {
return wait(undefined);
},
async updateProfile(input: ProfileInput): Promise<User> {
Object.assign(MOCK_USER, {
firstName: input.firstName ?? MOCK_USER.firstName,
lastName: input.lastName ?? MOCK_USER.lastName,
});
return wait({ ...MOCK_USER });
},
async uploadAvatar(file: LocalFile): Promise<User> {
MOCK_USER.avatar = file.uri;
return wait({ ...MOCK_USER });
},
},
properties: {
async list(): Promise<Property[]> {
return wait([...properties]);
},
async get(id: number): Promise<Property> {
const p = properties.find((x) => x.id === id);
if (!p) throw new Error('Property not found');
return wait({ ...p });
},
async create(input: CreatePropertyInput): Promise<Property> {
const p: Property = {
id: ++nextPropertyId,
name: input.name,
type: input.type,
address: input.address ?? null,
city: input.city ?? null,
zip: input.zip ?? null,
country: input.country ?? null,
description: input.description ?? null,
photoUrl: null,
latitude: input.latitude ?? null,
longitude: input.longitude ?? null,
documentCount: 0,
expiringCount: 0,
};
properties = [p, ...properties];
return wait({ ...p });
},
async update(input: UpdatePropertyInput): Promise<Property> {
const p = properties.find((x) => x.id === input.id);
if (!p) throw new Error('Property not found');
Object.assign(p, {
name: input.name,
type: input.type,
address: input.address ?? null,
city: input.city ?? null,
zip: input.zip ?? null,
country: input.country ?? null,
description: input.description ?? null,
latitude: input.latitude ?? null,
longitude: input.longitude ?? null,
});
return wait({ ...p });
},
async uploadPhoto(id: number, file: LocalFile): Promise<Property> {
const p = properties.find((x) => x.id === id);
if (!p) throw new Error('Property not found');
p.photoUrl = file.uri;
return wait({ ...p });
},
async remove(id: number): Promise<void> {
properties = properties.filter((x) => x.id !== id);
documents = documents.filter((d) => d.propertyId !== id);
return wait(undefined);
},
},
documents: {
async listByProperty(propertyId: number): Promise<Document[]> {
return wait(documents.filter((d) => d.propertyId === propertyId).map((d) => ({ ...d })));
},
async get(id: number): Promise<Document> {
const d = documents.find((x) => x.id === id);
if (!d) throw new Error('Document not found');
return wait({ ...d });
},
async upload(input: UploadInput): Promise<Document> {
const doc: Document = {
id: ++nextDocId,
propertyId: input.propertyId,
name: input.name,
category: input.category,
mimeType: input.file.mimeType,
sizeBytes: input.file.sizeBytes ?? 0,
fileUri: input.file.uri,
uploadedAt: new Date().toISOString().slice(0, 10),
expiryDate: input.expiryDate ?? null,
expiryStatus: deriveExpiryStatus(input.expiryDate),
pageCount: null,
note: input.note ?? null,
};
documents = [doc, ...documents];
recomputeCounts(input.propertyId);
return wait({ ...doc });
},
async update(input: UpdateDocumentInput): Promise<Document> {
const doc = documents.find((d) => d.id === input.id);
if (!doc) throw new Error('Document not found');
if ('expiryDate' in input) {
doc.expiryDate = input.expiryDate ?? null;
doc.expiryStatus = deriveExpiryStatus(input.expiryDate);
}
if ('note' in input) doc.note = input.note ?? null;
if ('title' in input && input.title) doc.name = input.title;
if (input.category) doc.category = input.category;
recomputeCounts(doc.propertyId);
return wait({ ...doc });
},
async replaceFile(id: number, file: LocalFile): Promise<Document> {
const doc = documents.find((d) => d.id === id);
if (!doc) throw new Error('Document not found');
doc.fileUri = file.uri;
doc.mimeType = file.mimeType;
doc.sizeBytes = file.sizeBytes ?? 0;
return wait({ ...doc });
},
async remove(id: number): Promise<void> {
const d = documents.find((x) => x.id === id);
documents = documents.filter((x) => x.id !== id);
if (d) recomputeCounts(d.propertyId);
return wait(undefined);
},
},
reference: {
async categories(): Promise<DocCategory[]> {
return wait([...CATEGORIES]);
},
async requirements(categorySlug: string): Promise<DocRequirement[]> {
return wait(
(MOCK_REQUIREMENTS[categorySlug] ?? MOCK_REQUIREMENTS.other).map((r, i) => ({
...r,
id: hashSlug(categorySlug) + i,
})),
);
},
},
};
+371
View File
@@ -0,0 +1,371 @@
import { get, postForm, postJson, fileUrl } from './http';
import { LocalFile } from './types';
import {
CasaDocApi,
CreatePropertyInput,
DocCategory,
DocRequirement,
Document,
ExpiryStatus,
LoginResult,
ProfileInput,
Property,
PropertyType,
RegisterInput,
UpdateDocumentInput,
UpdatePropertyInput,
UploadInput,
User,
} from './types';
import { DEVICE_NAME, BASE_URL } from './config';
// Backend client, mapped to openapi.yaml. Reads flatten the backend's
// page > section > requirement > files structure; writes stay faithful, since
// the upload screen sends a real requirement id.
// mappers
function guessMime(filename: string): string {
const ext = filename.toLowerCase().split('.').pop() ?? '';
switch (ext) {
case 'pdf':
return 'application/pdf';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'heic':
return 'image/heic';
default:
return 'application/octet-stream';
}
}
function mapExpiryStatus(raw?: unknown, expiryDate?: string | null): ExpiryStatus {
// The contract types `expiry_status` as a string, but the column is a
// tinyint and arrives as 0/1, so only known strings are trusted.
const v = typeof raw === 'string' ? raw.toLowerCase() : '';
if (v === 'valid' || v === 'expiring' || v === 'expired') return v as ExpiryStatus;
if (!expiryDate) return 'valid';
const days = (new Date(expiryDate).getTime() - Date.now()) / 86_400_000;
if (Number.isNaN(days)) return null;
if (days < 0) return 'expired';
if (days <= 60) return 'expiring';
return 'valid';
}
/** `photo_url` is absolute; `noimage.jpg` is the backend's "no photo". */
function photoUrlFor(h: any): string | null {
const url = typeof h.photo_url === 'string' ? h.photo_url.trim() : '';
if (!url || /\/noimage\.jpg$/i.test(url)) return null;
return url;
}
// snake_case to camelCase. Casting instead of mapping compiles but leaves
// every renamed field undefined at runtime.
function mapUser(u: any): User {
return {
id: u.id,
firstName: u.first_name ?? null,
lastName: u.last_name ?? null,
email: u.email,
username: u.username ?? null,
phone: u.phone ?? null,
address: u.address ?? null,
avatar: u.avatar ?? null,
};
}
function parseCoord(raw: unknown): number | null {
const n = typeof raw === 'string' ? parseFloat(raw) : typeof raw === 'number' ? raw : NaN;
return Number.isFinite(n) && n !== 0 ? n : null;
}
function mapHome(h: any): Property {
// `||` not `??`: unset text fields come back as empty strings, not nulls.
return {
id: h.idhome,
name: h.name || h.fulladdress || 'Property',
// The backend stores no property type yet.
type: 'apartment' as PropertyType,
address: h.address || h.fulladdress || null,
city: h.city || null,
zip: h.zip || null,
country: h.country || null,
description: h.comment || null,
photoUrl: photoUrlFor(h),
// Stored as strings on the backend; '' and '0' both mean "not set".
latitude: parseCoord(h.latitude),
longitude: parseCoord(h.longitude),
documentCount: typeof h.documents_count === 'number' ? h.documents_count : 0,
expiringCount: typeof h.expiring_count === 'number' ? h.expiring_count : 0,
};
}
function mapFile(file: any, propertyId: number, categorySlug: string, requirementName?: string): Document {
const filename = file.filename ?? requirementName ?? 'Document';
return {
id: file.id,
propertyId,
// The user's own title wins over the requirement's generic name.
name: file.title || requirementName || filename,
category: categorySlug,
mimeType: guessMime(filename),
sizeBytes: typeof file.size === 'number' ? file.size : 0,
// Built from BASE_URL: the `url` the backend returns is absolute against
// its own app_url (localhost), which would resolve to the phone.
fileUri: fileUrl('/document-file.php', { id: file.id }),
uploadedAt: file.created_at ?? '',
expiryDate: file.expiry_date ?? null,
expiryStatus: mapExpiryStatus(file.expiry_status, file.expiry_date),
pageCount: null,
note: file.note ?? null,
};
}
/** Static reference list, needed once per property. Cleared on logout. */
let pagesCache: Promise<any[]> | null = null;
function fetchPages(): Promise<any[]> {
if (!pagesCache) {
pagesCache = get('/pages.php')
.then((res) => (res.data as any[]) ?? [])
.catch((err) => {
pagesCache = null; // don't cache failures
throw err;
});
}
return pagesCache;
}
// client
export const realApi: CasaDocApi = {
auth: {
async login(login: string, password: string, deviceName: string): Promise<LoginResult> {
const res = await postJson('/login.php', {
login,
password,
device_name: deviceName || DEVICE_NAME,
});
return { token: res.data.token, user: mapUser(res.data.user) };
},
async me(): Promise<User> {
const res = await get('/me.php');
return mapUser(res.data);
},
async logout(): Promise<void> {
await postJson('/logout.php', {});
pagesCache = null;
},
async deleteAccount(): Promise<void> {
await postJson('/account-delete.php', { confirm: true });
pagesCache = null;
},
async register(input: RegisterInput): Promise<void> {
await postJson('/register.php', {
email: input.email,
password: input.password,
first_name: input.firstName || undefined,
last_name: input.lastName || undefined,
});
},
async verifyEmail(email: string, code: string, deviceName: string): Promise<LoginResult> {
const res = await postJson('/email-verify.php', {
email,
code,
device_name: deviceName || DEVICE_NAME,
});
return { token: res.data.token, user: mapUser(res.data.user) };
},
async resendVerification(email: string): Promise<void> {
await postJson('/email-verify-resend.php', { email });
},
async forgotPassword(email: string): Promise<void> {
await postJson('/password-forgot.php', { email });
},
async resetPassword(email: string, code: string, password: string): Promise<void> {
await postJson('/password-reset.php', { email, code, password });
},
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await postJson('/password-change.php', {
current_password: currentPassword,
new_password: newPassword,
});
},
async updateProfile(input: ProfileInput): Promise<User> {
// Partial update: only send the keys the caller actually set.
const body: Record<string, unknown> = {};
if ('firstName' in input) body.first_name = input.firstName ?? null;
if ('lastName' in input) body.last_name = input.lastName ?? null;
if ('phone' in input) body.phone = input.phone ?? null;
if ('address' in input) body.address = input.address ?? null;
const res = await postJson('/me-save.php', body);
return mapUser(res.data);
},
async uploadAvatar(file: LocalFile): Promise<User> {
const form = new FormData();
form.append('avatar', { uri: file.uri, name: file.name, type: file.mimeType } as any);
const res = await postForm('/me-avatar.php', form);
return mapUser(res.data);
},
},
properties: {
async list(): Promise<Property[]> {
const res = await get('/homes.php');
return (res.data as any[]).map(mapHome);
},
async get(id: number): Promise<Property> {
const res = await get('/home.php', { idhome: id });
return mapHome(res.data);
},
async create(input: CreatePropertyInput): Promise<Property> {
const res = await postJson('/home-save.php', {
name: input.name,
comment: input.description ?? undefined,
address: input.address ?? undefined,
city: input.city ?? undefined,
zip: input.zip ?? undefined,
country: input.country ?? undefined,
latitude: input.latitude != null ? String(input.latitude) : undefined,
longitude: input.longitude != null ? String(input.longitude) : undefined,
});
return mapHome(res.data);
},
async update(input: UpdatePropertyInput): Promise<Property> {
const res = await postJson('/home-save.php', {
idhome: input.id,
name: input.name,
comment: input.description ?? '',
address: input.address ?? '',
city: input.city ?? '',
zip: input.zip ?? '',
country: input.country ?? '',
latitude: input.latitude != null ? String(input.latitude) : '',
longitude: input.longitude != null ? String(input.longitude) : '',
});
return mapHome(res.data);
},
async uploadPhoto(id: number, file: LocalFile): Promise<Property> {
const form = new FormData();
form.append('idhome', String(id));
form.append('photo', { uri: file.uri, name: file.name, type: file.mimeType } as any);
const res = await postForm('/home-photo.php', form);
return mapHome(res.data);
},
async remove(id: number): Promise<void> {
// The backend cascades: uploaded files, sharing rows and the home itself.
await postJson('/home-delete.php', { idhome: id });
},
},
documents: {
async listByProperty(propertyId: number): Promise<Document[]> {
// One request per category page: the endpoint serves a single page at a
// time, and a property's files are spread across all of them.
const pages = await fetchPages();
const perPage = await Promise.all(
pages.map(async (pg) => {
const res = await get('/documents.php', { idhome: propertyId, slug: pg.slug });
const requirements = (res.data as any[]) ?? [];
return requirements.flatMap((req) =>
(req.files ?? []).map((f: any) => mapFile(f, propertyId, pg.slug, req.document_name)),
);
}),
);
return perPage.flat();
},
async get(id: number): Promise<Document> {
// The API exposes no per-file metadata endpoint, only the binary.
throw new Error(
`documents.get(${id}) is not directly supported by the backend — open the document from its property list.`,
);
},
async upload(input: UploadInput): Promise<Document> {
// Falling back to the first requirement is a guess that files the
// document in the wrong place, so it only runs if the picker had none.
let documentId = input.requirementId;
if (!documentId) {
const templatesRes = await get('/document-templates.php', { slug: input.category });
documentId = ((templatesRes.data as any[]) ?? [])[0]?.document_id;
}
if (!documentId) {
throw new Error(
`No document requirement found for category "${input.category}". ` +
`Pick a category that maps to a backend requirement.`,
);
}
const form = new FormData();
form.append('idhome', String(input.propertyId));
form.append('document_id', String(documentId));
if (input.name) form.append('title', input.name);
if (input.expiryDate) form.append('expiry_date', input.expiryDate);
if (input.note) form.append('note', input.note);
form.append('file', {
uri: input.file.uri,
name: input.file.name,
type: input.file.mimeType,
} as any);
const res = await postForm('/document-upload.php', form);
return mapFile(res.data, input.propertyId, input.category, input.name);
},
async update(input: UpdateDocumentInput): Promise<Document> {
// Omitted keys stay untouched; an explicit null clears the value.
const body: Record<string, unknown> = { id: input.id };
if ('expiryDate' in input) body.expiry_date = input.expiryDate ?? null;
if ('note' in input) body.note = input.note ?? null;
if ('title' in input) body.title = input.title ?? null;
if (input.category) {
// Re-filing means picking a requirement that belongs to the new page.
const templatesRes = await get('/document-templates.php', { slug: input.category });
const documentId = ((templatesRes.data as any[]) ?? [])[0]?.document_id;
if (!documentId) {
throw new Error(`No document requirement found for category "${input.category}".`);
}
body.document_id = documentId;
}
const res = await postJson('/document-file-update.php', body);
const file = res.data;
return mapFile(file, file.idhome ?? 0, input.category ?? '', undefined);
},
async replaceFile(id: number, file: LocalFile): Promise<Document> {
// Not delete-then-upload: `max_documents` is often 1, so uploading first
// is rejected and deleting first loses the file if the upload fails.
const form = new FormData();
form.append('id', String(id));
form.append('file', { uri: file.uri, name: file.name, type: file.mimeType } as any);
const res = await postForm('/document-file-replace.php', form);
const f = res.data;
// The response carries no slug or requirement name, so callers refetch
// rather than trusting the category and name returned here.
return mapFile(f, f.idhome ?? 0, '', undefined);
},
async remove(id: number): Promise<void> {
await postJson('/document-file-delete.php', { id });
},
},
reference: {
async categories(): Promise<DocCategory[]> {
const pages = await fetchPages();
return pages.map((p) => ({ slug: p.slug, name: p.namepages }));
},
async requirements(categorySlug: string): Promise<DocRequirement[]> {
const res = await get('/document-templates.php', { slug: categorySlug });
return ((res.data as any[]) ?? []).map((tpl) => ({
id: tpl.document_id,
name: tpl.document_name,
sectionName: tpl.section_name ?? null,
isRequired: !!tpl.is_required,
maxDocuments: typeof tpl.max_documents === 'number' ? tpl.max_documents : 1,
}));
},
},
};
+186
View File
@@ -0,0 +1,186 @@
// The screens see a property owning a flat list of documents. The backend
// nests the same data as page > section > requirement > files; `real.ts` maps
// between the two.
export interface User {
id: number;
firstName: string | null;
lastName: string | null;
email: string;
username?: string | null;
phone?: string | null;
address?: string | null;
avatar?: string | null;
}
export type PropertyType =
| 'apartment'
| 'house'
| 'villa'
| 'commercial'
| 'land'
| 'building';
export interface Property {
id: number;
name: string;
type: PropertyType;
address: string | null;
city: string | null;
zip: string | null;
country: string | null;
description: string | null;
photoUrl: string | null;
/** Decimal degrees; null when the property has no pin on the map yet. */
latitude: number | null;
longitude: number | null;
documentCount: number;
expiringCount: number;
}
export type ExpiryStatus = 'valid' | 'expiring' | 'expired' | null;
export interface DocCategory {
slug: string;
name: string;
}
/** The unit of filing: every uploaded file hangs off exactly one of these. */
export interface DocRequirement {
id: number;
name: string;
sectionName: string | null;
isRequired: boolean;
/** How many files this requirement accepts; often 1. */
maxDocuments: number;
}
export interface Document {
id: number;
propertyId: number;
name: string;
category: string; // slug, e.g. "certifications"
mimeType: string; // "application/pdf" | "image/jpeg" ...
sizeBytes: number;
fileUri: string | null; // local uri (mock) or remote download url (real)
uploadedAt: string; // ISO date
expiryDate: string | null; // ISO date
expiryStatus: ExpiryStatus;
pageCount: number | null;
note?: string | null;
}
export interface LoginResult {
token: string;
user: User;
}
export interface RegisterInput {
email: string;
password: string;
firstName?: string;
lastName?: string;
}
/** Partial profile update; omitted keys stay untouched. */
export interface ProfileInput {
firstName?: string | null;
lastName?: string | null;
phone?: string | null;
address?: string | null;
}
/** Payload used by the upload flow (file may come from picker, gallery or scan). */
export interface UploadInput {
propertyId: number;
name: string;
category: string;
/** Optional only for the mock; omitting it against the real API means guessing. */
requirementId?: number;
expiryDate?: string | null;
note?: string | null;
file: LocalFile;
}
export interface LocalFile {
uri: string;
name: string;
mimeType: string;
sizeBytes?: number;
}
/** Only the provided keys change; an explicit `null` clears the value. */
export interface UpdateDocumentInput {
id: number;
/** Display name; `null` resets it to the requirement's own name. */
title?: string | null;
expiryDate?: string | null;
note?: string | null;
/** Move the file under a different requirement (i.e. another category). */
category?: string;
}
export interface UpdatePropertyInput extends CreatePropertyInput {
id: number;
}
export interface CreatePropertyInput {
name: string;
type: PropertyType;
address?: string | null;
city?: string | null;
zip?: string | null;
country?: string | null;
description?: string | null;
latitude?: number | null;
longitude?: number | null;
}
/** The contract every backend implementation (mock/real) must satisfy. */
export interface CasaDocApi {
auth: {
login(login: string, password: string, deviceName: string): Promise<LoginResult>;
me(): Promise<User>;
logout(): Promise<void>;
/** Required in-app by App Store 5.1.1(v) and Play's account deletion policy. */
deleteAccount(): Promise<void>;
/** Creates the account. No session yet — the e-mail must be verified first. */
register(input: RegisterInput): Promise<void>;
/** Confirms the e-mailed code and returns a session. */
verifyEmail(email: string, code: string, deviceName: string): Promise<LoginResult>;
resendVerification(email: string): Promise<void>;
/** Always resolves, even for unknown addresses — see the backend spec. */
forgotPassword(email: string): Promise<void>;
resetPassword(email: string, code: string, password: string): Promise<void>;
changePassword(currentPassword: string, newPassword: string): Promise<void>;
updateProfile(input: ProfileInput): Promise<User>;
uploadAvatar(file: LocalFile): Promise<User>;
};
properties: {
list(): Promise<Property[]>;
get(id: number): Promise<Property>;
create(input: CreatePropertyInput): Promise<Property>;
update(input: UpdatePropertyInput): Promise<Property>;
/** Uploads the property's main photo (multipart) and returns the property. */
uploadPhoto(id: number, file: LocalFile): Promise<Property>;
/** Deletes the property along with its documents and shares (cascade). */
remove(id: number): Promise<void>;
};
documents: {
listByProperty(propertyId: number): Promise<Document[]>;
get(id: number): Promise<Document>;
upload(input: UploadInput): Promise<Document>;
update(input: UpdateDocumentInput): Promise<Document>;
/** Swaps the file, keeping the document's id, date and metadata. */
replaceFile(id: number, file: LocalFile): Promise<Document>;
remove(id: number): Promise<void>;
};
reference: {
categories(): Promise<DocCategory[]>;
/** The requirements belonging to one category page. */
requirements(categorySlug: string): Promise<DocRequirement[]>;
};
}
+94
View File
@@ -0,0 +1,94 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { api, ProfileInput, setAuthToken, User } from '@/api';
import { DEVICE_NAME } from '@/api/config';
import { clearPersistedCache } from '@/lib/queryClient';
import { clearToken, loadToken, saveToken } from './tokenStore';
interface AuthState {
user: User | null;
/** True until SecureStore has been checked for an existing session. */
initializing: boolean;
signIn: (login: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
deleteAccount: () => Promise<void>;
/** Starts a session from a verified e-mail code. */
signInWithVerification: (email: string, code: string) => Promise<void>;
/** Applies a profile change without a refetch. */
applyUser: (user: User) => void;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [initializing, setInitializing] = useState(true);
useEffect(() => {
(async () => {
try {
const token = await loadToken();
if (token) {
setAuthToken(token);
const me = await api.auth.me();
setUser(me);
}
} catch {
// Stale token: drop it and fall back to login.
setAuthToken(null);
await clearToken();
} finally {
setInitializing(false);
}
})();
}, []);
const value = useMemo<AuthState>(
() => ({
user,
initializing,
async signIn(login: string, password: string) {
const { token, user: u } = await api.auth.login(login, password, DEVICE_NAME);
setAuthToken(token);
await saveToken(token);
setUser(u);
},
async signInWithVerification(email: string, code: string) {
const { token, user: u } = await api.auth.verifyEmail(email, code, DEVICE_NAME);
setAuthToken(token);
await saveToken(token);
setUser(u);
},
applyUser(u: User) {
setUser(u);
},
async deleteAccount() {
await api.auth.deleteAccount();
setAuthToken(null);
await clearToken();
await clearPersistedCache();
setUser(null);
},
async signOut() {
try {
await api.auth.logout();
} catch {
// Best-effort; log out locally regardless.
}
setAuthToken(null);
await clearToken();
// The on-disk cache holds this user's data.
await clearPersistedCache();
setUser(null);
},
}),
[user, initializing],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>');
return ctx;
}
+15
View File
@@ -0,0 +1,15 @@
import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'casadoc.token';
export async function saveToken(token: string): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
export async function loadToken(): Promise<string | null> {
return SecureStore.getItemAsync(TOKEN_KEY);
}
export async function clearToken(): Promise<void> {
await SecureStore.deleteItemAsync(TOKEN_KEY);
}
+63
View File
@@ -0,0 +1,63 @@
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 { useRouter } from 'expo-router';
import { colors, fontSize, spacing } from '@/theme';
interface AppBarProps {
title: string;
subtitle?: string;
back?: boolean;
right?: React.ReactNode;
large?: boolean;
}
export function AppBar({ title, subtitle, back, right, large }: AppBarProps) {
const insets = useSafeAreaInsets();
const router = useRouter();
return (
<View style={[styles.bar, { paddingTop: insets.top + spacing[2] }]}>
<View style={styles.row}>
{back ? (
<Pressable
onPress={() => router.back()}
hitSlop={12}
style={({ pressed }) => [styles.back, pressed && { opacity: 0.6 }]}
>
<Ionicons name="chevron-back" size={24} color={colors.ink} />
</Pressable>
) : (
<View style={{ width: large ? 0 : 8 }} />
)}
<View style={{ flex: 1 }}>
<Text style={[styles.title, large && styles.titleLarge]} numberOfLines={1}>
{title}
</Text>
{subtitle ? (
<Text style={styles.subtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View>
{right ?? <View style={{ width: back ? 36 : 8 }} />}
</View>
</View>
);
}
const styles = StyleSheet.create({
bar: {
backgroundColor: colors.bgApp,
paddingHorizontal: spacing[4],
paddingBottom: spacing[3],
},
row: { flexDirection: 'row', alignItems: 'center', gap: spacing[2], minHeight: 40 },
back: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', marginLeft: -8 },
title: { fontSize: fontSize.lg, fontWeight: '700', color: colors.ink },
titleLarge: { fontSize: fontSize['2xl'], letterSpacing: -0.3 },
subtitle: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: 1 },
});
+109
View File
@@ -0,0 +1,109 @@
import React 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 { useRouter } from 'expo-router';
import { colors, fontSize, radii, spacing } from '@/theme';
/** Shared frame for the signed-out screens: brand, title, scrollable body. */
export function AuthShell({
title,
subtitle,
back,
error,
children,
}: {
title: string;
subtitle?: string;
back?: boolean;
error?: string | null;
children: React.ReactNode;
}) {
const insets = useSafeAreaInsets();
const router = useRouter();
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.surface }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={[
styles.container,
{ paddingTop: insets.top + spacing[5], paddingBottom: insets.bottom + spacing[6] },
]}
keyboardShouldPersistTaps="handled"
>
{back ? (
<Pressable onPress={() => router.back()} hitSlop={12} style={styles.back}>
<Ionicons name="chevron-back" size={24} color={colors.ink} />
</Pressable>
) : null}
<View style={styles.head}>
<View style={styles.mark}>
<Ionicons name="home" size={24} color={colors.onDark} />
</View>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
{error ? (
<View style={styles.errorBox}>
<Ionicons name="alert-circle-outline" size={18} color={colors.dangerText} />
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
{children}
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { paddingHorizontal: spacing[5], flexGrow: 1 },
back: { alignSelf: 'flex-start', marginLeft: -6, marginBottom: spacing[2] },
head: { alignItems: 'center', marginBottom: spacing[7] },
mark: {
width: 54,
height: 54,
borderRadius: 16,
backgroundColor: colors.ink900,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: fontSize['2xl'],
fontWeight: '700',
color: colors.ink,
marginTop: spacing[4],
textAlign: 'center',
},
subtitle: {
fontSize: fontSize.sm,
color: colors.textMuted,
textAlign: 'center',
marginTop: 8,
maxWidth: 300,
lineHeight: 20,
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.dangerBg,
borderRadius: radii.md,
padding: spacing[3],
marginBottom: spacing[4],
},
errorText: { color: colors.dangerText, fontSize: fontSize.sm, flex: 1 },
});
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { StyleSheet, TextInput } from 'react-native';
import { colors, fontSize, radii, spacing } from '@/theme';
export const CODE_LENGTH = 6;
/** Input for the 6-digit codes the backend e-mails out. */
export function CodeField({
value,
onChange,
onComplete,
autoFocus,
}: {
value: string;
onChange: (next: string) => void;
/** Fired once the last digit lands. */
onComplete?: (code: string) => void;
autoFocus?: boolean;
}) {
return (
<TextInput
value={value}
onChangeText={(raw) => {
const digits = raw.replace(/\D/g, '').slice(0, CODE_LENGTH);
onChange(digits);
if (digits.length === CODE_LENGTH) onComplete?.(digits);
}}
keyboardType="number-pad"
maxLength={CODE_LENGTH}
autoFocus={autoFocus}
// Lets both platforms offer the code from the notification.
textContentType="oneTimeCode"
autoComplete="one-time-code"
placeholder="––––––"
placeholderTextColor={colors.textMuted2}
style={styles.input}
/>
);
}
const styles = StyleSheet.create({
input: {
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
paddingVertical: spacing[4],
fontSize: fontSize['3xl'],
fontWeight: '700',
color: colors.ink,
textAlign: 'center',
letterSpacing: 12,
},
});
+89
View File
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Ionicons } from '@expo/vector-icons';
import { useT } from '@/i18n';
import { formatDate } from '@/lib/format';
import { colors, fontSize, radii, spacing } from '@/theme';
/** Native picker bound to an ISO `YYYY-MM-DD` string, the format the API takes. */
export function DateField({
value,
onChange,
placeholder,
minimumDate,
}: {
value: string | null;
onChange: (iso: string | null) => void;
placeholder?: string;
minimumDate?: Date;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const parsed = value ? new Date(value) : null;
const valid = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null;
return (
<View>
<Pressable style={styles.field} onPress={() => setOpen(true)}>
<Ionicons name="calendar-outline" size={18} color={colors.slate400} />
<Text style={[styles.value, !valid && styles.placeholder]}>
{valid ? formatDate(value) : placeholder ?? t('common.optional')}
</Text>
{valid ? (
<Pressable onPress={() => onChange(null)} hitSlop={10}>
<Ionicons name="close-circle" size={18} color={colors.slate400} />
</Pressable>
) : (
<Ionicons name="chevron-down" size={18} color={colors.slate400} />
)}
</Pressable>
{open ? (
<DateTimePicker
value={valid ?? new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'default'}
minimumDate={minimumDate}
onChange={(event, date) => {
// Android fires once and closes itself; iOS keeps the inline picker.
if (Platform.OS === 'android') setOpen(false);
if (event.type === 'dismissed') return;
if (date) onChange(toIso(date));
}}
/>
) : null}
{open && Platform.OS === 'ios' ? (
<Pressable style={styles.done} onPress={() => setOpen(false)}>
<Text style={styles.doneText}>OK</Text>
</Pressable>
) : null}
</View>
);
}
/** Local calendar date, not UTC, which would shift the day. */
function toIso(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
const styles = StyleSheet.create({
field: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
paddingHorizontal: spacing[4],
minHeight: 50,
},
value: { flex: 1, fontSize: fontSize.md, color: colors.ink },
placeholder: { color: colors.textMuted2 },
done: { alignSelf: 'flex-end', padding: spacing[2] },
doneText: { color: colors.teal700, fontWeight: '700', fontSize: fontSize.md },
});
+47
View File
@@ -0,0 +1,47 @@
import React, { useContext } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useIsOnline } from '@/lib/network';
import { useT } from '@/i18n';
import { colors, fontSize, spacing } from '@/theme';
/**
* Slim "you're offline" bar, so stale lists don't read as current data. It
* takes the top safe-area inset itself, and zeroes it for its children so
* headers below aren't pushed down by the status bar twice.
*/
export function OfflineGate({ children }: { children: React.ReactNode }) {
const online = useIsOnline();
const insets = useContext(SafeAreaInsetsContext);
const t = useT();
if (online) return <>{children}</>;
return (
<>
<View style={[styles.bar, { paddingTop: (insets?.top ?? 0) + spacing[2] }]}>
<Ionicons name="cloud-offline-outline" size={15} color={colors.onDark} />
<Text style={styles.text}>{t('network.offline')}</Text>
</View>
<SafeAreaInsetsContext.Provider
value={{ top: 0, bottom: insets?.bottom ?? 0, left: insets?.left ?? 0, right: insets?.right ?? 0 }}
>
{children}
</SafeAreaInsetsContext.Provider>
</>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 7,
paddingBottom: spacing[2],
paddingHorizontal: spacing[4],
backgroundColor: colors.ink800,
},
text: { color: colors.onDark, fontSize: fontSize.xs, fontWeight: '600' },
});
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useT } from '@/i18n';
import { colors, fontSize, radii, spacing } from '@/theme';
/** Card standing in for the PDF; tapping hands the file to the phone's viewer. */
export function PdfView({
onPress,
ready,
style,
}: {
onPress?: () => void;
/** False while the file is still downloading. */
ready?: boolean;
style?: any;
}) {
const t = useT();
return (
<Pressable style={[styles.card, style]} onPress={ready ? onPress : undefined}>
<View style={styles.iconWrap}>
<Ionicons name="document-text-outline" size={34} color={colors.danger600} />
</View>
<Text style={styles.title}>{t('document.pdfCard')}</Text>
<View style={styles.openRow}>
<Ionicons
name={ready ? 'phone-portrait-outline' : 'cloud-download-outline'}
size={15}
color={colors.teal700}
/>
<Text style={styles.openText}>
{ready ? t('document.pdfTapToOpen') : t('document.pdfPreparing')}
</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
card: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surface,
borderRadius: radii.md,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: spacing[7],
gap: spacing[2],
},
iconWrap: {
width: 72,
height: 72,
borderRadius: radii.md,
backgroundColor: colors.dangerBg,
alignItems: 'center',
justifyContent: 'center',
},
title: { fontSize: fontSize.md, fontWeight: '600', color: colors.ink, marginTop: spacing[2] },
openRow: { flexDirection: 'row', alignItems: 'center', gap: 5 },
openText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.teal700 },
});
+104
View File
@@ -0,0 +1,104 @@
import React from 'react';
import { Image, StyleSheet, Text, View, ViewStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { PropertyType } from '@/api';
import { useT } from '@/i18n';
import { colors, coverGradient, fontSize, radii, spacing } from '@/theme';
const PROPERTY_ICON: Record<string, keyof typeof Ionicons.glyphMap> = {
apartment: 'business-outline',
house: 'home-outline',
villa: 'home-outline',
commercial: 'briefcase-outline',
land: 'map-outline',
building: 'business-outline',
};
/** The photo, or a type-coloured gradient that names the missing one. */
export function PropertyCover({
photoUrl,
type,
height,
compact,
style,
}: {
photoUrl?: string | null;
type?: PropertyType;
height: number;
/** Smaller icon and caption, for list cards. */
compact?: boolean;
style?: ViewStyle;
}) {
const t = useT();
const icon = PROPERTY_ICON[type ?? 'apartment'] ?? 'home-outline';
if (photoUrl) {
return (
<View style={[{ height }, styles.wrap, style]}>
<Image source={{ uri: photoUrl }} style={styles.image} resizeMode="cover" />
{/* Keeps overlaid white text legible on bright photos. */}
<LinearGradient
colors={['transparent', 'rgba(18,27,36,0.35)']}
style={StyleSheet.absoluteFill as ViewStyle}
/>
</View>
);
}
return (
<LinearGradient
colors={coverGradient(type)}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[{ height }, styles.wrap, styles.empty, style]}
>
{/* Faint diagonal sheen — texture, so the block reads as designed. */}
<View style={StyleSheet.absoluteFill} pointerEvents="none">
{[0, 1, 2, 3].map((i) => (
<View key={i} style={[styles.stripe, { top: i * 44 - 24 }]} />
))}
</View>
<View style={[styles.badge, compact && styles.badgeCompact]}>
<Ionicons name={icon} size={compact ? 20 : 26} color="#fff" />
</View>
<Text style={[styles.caption, compact && styles.captionCompact]}>
{t('properties.noPhoto')}
</Text>
</LinearGradient>
);
}
const styles = StyleSheet.create({
wrap: { width: '100%', overflow: 'hidden', backgroundColor: colors.ink800 },
image: { width: '100%', height: '100%' },
empty: { alignItems: 'center', justifyContent: 'center', gap: spacing[2] },
stripe: {
position: 'absolute',
left: -160,
right: -160,
height: 18,
backgroundColor: 'rgba(255,255,255,0.05)',
transform: [{ rotate: '-18deg' }],
},
badge: {
width: 54,
height: 54,
borderRadius: radii.lg,
backgroundColor: 'rgba(255,255,255,0.14)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.24)',
alignItems: 'center',
justifyContent: 'center',
},
badgeCompact: { width: 42, height: 42, borderRadius: radii.md },
caption: {
fontSize: fontSize.xs,
fontWeight: '700',
color: 'rgba(255,255,255,0.78)',
letterSpacing: 0.8,
textTransform: 'uppercase',
},
captionCompact: { fontSize: fontSize['2xs'], letterSpacing: 0.6 },
});
+430
View File
@@ -0,0 +1,430 @@
import React, { useEffect, useState } from 'react';
import {
Alert,
Image,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useFocusEffect, useRouter } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Ionicons } from '@expo/vector-icons';
import { api, LocalFile, Property, PropertyType } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Button, Field, Loading, TextField } from '@/components/ui';
import { pickImageFromLibrary, takePhotoWithCamera } from '@/lib/pickImage';
import { PickedLocation, takePendingLocation } from '@/lib/pendingLocation';
import { colors, fontSize, radii, spacing } from '@/theme';
const TYPES: PropertyType[] = ['apartment', 'house', 'villa', 'commercial', 'land', 'building'];
/** Create and edit in one form; `propertyId` switches the mode. */
export function PropertyForm({ propertyId }: { propertyId?: number }) {
const router = useRouter();
const qc = useQueryClient();
const t = useT();
const isEdit = propertyId != null;
const existing = useQuery({
queryKey: ['property', propertyId],
queryFn: () => api.properties.get(propertyId as number),
enabled: isEdit,
});
const [name, setName] = useState('');
const [type, setType] = useState<PropertyType>('apartment');
const [address, setAddress] = useState('');
const [zip, setZip] = useState('');
const [city, setCity] = useState('');
const [description, setDescription] = useState('');
const [photo, setPhoto] = useState<LocalFile | null>(null);
const [coords, setCoords] = useState<{ latitude: number; longitude: number } | null>(null);
const [loaded, setLoaded] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const p = existing.data;
if (!p || loaded) return;
setName(p.name ?? '');
setType(p.type);
setAddress(p.address ?? '');
setZip(p.zip ?? '');
setCity(p.city ?? '');
setDescription(p.description ?? '');
if (p.latitude != null && p.longitude != null) {
setCoords({ latitude: p.latitude, longitude: p.longitude });
}
setLoaded(true);
}, [existing.data, loaded]);
const save = useMutation({
mutationFn: async (): Promise<Property> => {
const payload = {
name: name.trim(),
type,
address: address.trim() || null,
zip: zip.trim() || null,
city: city.trim() || null,
description: description.trim() || null,
latitude: coords?.latitude ?? null,
longitude: coords?.longitude ?? null,
};
const saved = isEdit
? await api.properties.update({ id: propertyId as number, ...payload })
: await api.properties.create(payload);
// The photo endpoint needs an id, so it goes up after the property.
if (photo) {
try {
return await api.properties.uploadPhoto(saved.id, photo);
} catch (e: any) {
Alert.alert(t('addProperty.photoFailed'), e?.message ?? t('common.tryAgain'));
}
}
return saved;
},
onSuccess: (saved) => {
qc.invalidateQueries({ queryKey: ['properties'] });
qc.invalidateQueries({ queryKey: ['property', saved.id] });
if (isEdit) router.back();
else router.replace(`/property/${saved.id}`);
},
onError: (e: any) => Alert.alert(t('addProperty.saveFailed'), e?.message ?? t('common.tryAgain')),
});
// The map picker is a separate screen; collect its result on the way back.
useFocusEffect(
React.useCallback(() => {
const picked = takePendingLocation();
if (!picked) return;
setCoords({ latitude: picked.latitude, longitude: picked.longitude });
applyGeocodedAddress(picked);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, zip, city]),
);
/** Silent when the fields are empty, asks before overwriting typed text. */
function applyGeocodedAddress(picked: PickedLocation) {
const next = {
address: picked.address ?? '',
zip: picked.postalCode ?? '',
city: picked.city ?? '',
};
if (!next.address && !next.zip && !next.city) return;
const apply = () => {
if (next.address) setAddress(next.address);
if (next.zip) setZip(next.zip);
if (next.city) setCity(next.city);
};
const wouldOverwrite =
(next.address && address.trim() && next.address !== address.trim()) ||
(next.zip && zip.trim() && next.zip !== zip.trim()) ||
(next.city && city.trim() && next.city !== city.trim());
if (!wouldOverwrite) {
apply();
return;
}
Alert.alert(
t('location.fillTitle'),
t('location.fillBody', {
address: [next.address, next.zip, next.city].filter(Boolean).join(', '),
}),
[
{ text: t('location.fillKeep'), style: 'cancel' },
{ text: t('location.fillApply'), onPress: apply },
],
);
}
function openMap() {
const q = new URLSearchParams();
if (coords) {
q.set('lat', String(coords.latitude));
q.set('lon', String(coords.longitude));
}
const full = [address.trim(), zip.trim(), city.trim()].filter(Boolean).join(', ');
if (full) q.set('address', full);
router.push(`/property/location?${q.toString()}`);
}
// Standing in front of the place is the common case, so camera first.
function pickPhoto() {
const opts = { allowsEditing: true, aspect: [16, 10] as [number, number], fallbackName: 'cover' };
Alert.alert(t('addProperty.photoSourceTitle'), undefined, [
{
text: t('addProperty.takePhoto'),
onPress: async () => {
const picked = await takePhotoWithCamera(opts);
if (picked) setPhoto(picked);
},
},
{
text: t('addProperty.fromGallery'),
onPress: async () => {
const picked = await pickImageFromLibrary(opts);
if (picked) setPhoto(picked);
},
},
{ text: t('common.cancel'), style: 'cancel' },
]);
}
// Irreversible, so the confirmation names the property and its documents.
function confirmDelete() {
const docs = (qc.getQueryData(['documents', propertyId]) as unknown[] | undefined)?.length ?? 0;
Alert.alert(
t('editProperty.deleteTitle'),
t(docs > 0 ? 'editProperty.deleteBodyWithDocs' : 'editProperty.deleteBody', {
name: name.trim() || t('property.fallbackTitle'),
count: docs,
}),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: async () => {
setDeleting(true);
try {
await api.properties.remove(propertyId as number);
qc.invalidateQueries({ queryKey: ['properties'] });
qc.removeQueries({ queryKey: ['property', propertyId] });
qc.removeQueries({ queryKey: ['documents', propertyId] });
qc.invalidateQueries({ queryKey: ['all-documents'] });
// The detail screen behind us is gone too.
router.replace('/(tabs)');
} catch (e: any) {
Alert.alert(t('editProperty.deleteFailed'), e?.message ?? t('common.tryAgain'));
} finally {
setDeleting(false);
}
},
},
],
);
}
function onSave() {
if (!name.trim()) {
return Alert.alert(t('addProperty.nameRequired'), t('addProperty.nameRequiredBody'));
}
save.mutate();
}
const coverUri = photo?.uri ?? existing.data?.photoUrl ?? null;
if (isEdit && existing.isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar title={t('editProperty.title')} back />
<Loading />
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bgApp }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<AppBar title={isEdit ? t('editProperty.title') : t('addProperty.title')} back />
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{/* Cover photo */}
<Pressable style={styles.cover} onPress={pickPhoto}>
{coverUri ? (
<>
<Image source={{ uri: coverUri }} style={styles.coverImage} resizeMode="cover" />
<View style={styles.coverEdit}>
<Ionicons name="camera" size={16} color={colors.onDark} />
<Text style={styles.coverEditText}>{t('addProperty.changePhoto')}</Text>
</View>
</>
) : (
<View style={styles.coverEmpty}>
<Ionicons name="camera-outline" size={26} color={colors.teal600} />
<Text style={styles.coverEmptyText}>{t('addProperty.addPhoto')}</Text>
</View>
)}
</Pressable>
<Field label={t('addProperty.name')}>
<TextField
icon="home-outline"
value={name}
onChangeText={setName}
placeholder={t('addProperty.namePlaceholder')}
autoCapitalize="words"
returnKeyType="next"
/>
</Field>
<Field label={t('addProperty.type')}>
<View style={styles.typeWrap}>
{TYPES.map((value) => {
const active = value === type;
return (
<Pressable
key={value}
onPress={() => setType(value)}
style={[styles.typeChip, active && styles.typeChipActive]}
>
<Text style={[styles.typeText, active && { color: colors.teal700 }]}>
{t(`propertyTypes.${value}`)}
</Text>
</Pressable>
);
})}
</View>
</Field>
<Field label={t('addProperty.address')}>
<TextField
icon="location-outline"
value={address}
onChangeText={setAddress}
placeholder={t('addProperty.addressPlaceholder')}
autoCapitalize="words"
textContentType="streetAddressLine1"
/>
</Field>
<View style={{ flexDirection: 'row', gap: spacing[3] }}>
<Field label={t('addProperty.zip')} style={{ flex: 1 }}>
<TextField
value={zip}
onChangeText={setZip}
placeholder="20121"
keyboardType="number-pad"
maxLength={10}
textContentType="postalCode"
/>
</Field>
<Field label={t('addProperty.city')} style={{ flex: 2 }}>
<TextField
value={city}
onChangeText={setCity}
placeholder="Milano"
autoCapitalize="words"
textContentType="addressCity"
/>
</Field>
</View>
<Field label={t('location.field')} optional>
<Pressable style={styles.locationRow} onPress={openMap}>
<Ionicons name={coords ? 'pin' : 'map-outline'} size={18} color={colors.teal600} />
<View style={{ flex: 1 }}>
<Text style={[styles.locationValue, !coords && styles.locationEmpty]}>
{coords
? `${coords.latitude.toFixed(5)}, ${coords.longitude.toFixed(5)}`
: t('location.notSet')}
</Text>
</View>
<Text style={styles.locationAction}>
{coords ? t('location.change') : t('location.set')}
</Text>
</Pressable>
</Field>
<Field label={t('addProperty.description')} optional>
<TextField
value={description}
onChangeText={setDescription}
placeholder={t('addProperty.descriptionPlaceholder')}
multiline
numberOfLines={4}
style={{ minHeight: 90, textAlignVertical: 'top' }}
/>
</Field>
<Button
title={isEdit ? t('editProperty.save') : t('addProperty.save')}
icon="checkmark"
size="lg"
block
loading={save.isPending}
onPress={onSave}
style={{ marginTop: spacing[2] }}
/>
{isEdit ? (
<Button
title={t('editProperty.delete')}
variant="danger"
icon="trash-outline"
block
loading={deleting}
onPress={confirmDelete}
style={{ marginTop: spacing[3] }}
/>
) : null}
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
body: { padding: spacing[4], paddingBottom: spacing[9] },
cover: {
height: 150,
borderRadius: radii.lg,
overflow: 'hidden',
marginBottom: spacing[5],
backgroundColor: colors.surface,
borderWidth: 1.5,
borderColor: colors.border,
borderStyle: 'dashed',
},
coverImage: { width: '100%', height: '100%' },
coverEmpty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 },
coverEmptyText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.teal700 },
coverEdit: {
position: 'absolute',
right: spacing[3],
bottom: spacing[3],
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: 'rgba(18,27,36,0.72)',
paddingHorizontal: 12,
height: 30,
borderRadius: radii.pill,
},
coverEditText: { color: colors.onDark, fontSize: fontSize.xs, fontWeight: '600' },
locationRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
paddingHorizontal: spacing[4],
minHeight: 50,
},
locationValue: { fontSize: fontSize.md, color: colors.ink, fontWeight: '600' },
locationEmpty: { color: colors.textMuted2, fontWeight: '400' },
locationAction: { fontSize: fontSize.sm, fontWeight: '600', color: colors.teal700 },
typeWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
typeChip: {
paddingHorizontal: 14,
height: 36,
borderRadius: radii.pill,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
alignItems: 'center',
justifyContent: 'center',
},
typeChipActive: { backgroundColor: colors.teal50, borderColor: colors.teal300 },
typeText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.slate600 },
});
+168
View File
@@ -0,0 +1,168 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { categoryColor, colors, fontSize, radii, spacing } from '@/theme';
import { useT } from '@/i18n';
import { Document, Property } from '@/api';
import { expiryLabel, formatBytes, formatDate } from '@/lib/format';
import { Badge, BadgeTone, Card } from './ui';
import { PropertyCover } from './PropertyCover';
export function PropertyCard({ property, onPress }: { property: Property; onPress?: () => void }) {
const t = useT();
const addressLine = [property.address, property.city].filter(Boolean).join(', ');
const statusTone: BadgeTone = property.expiringCount > 0 ? 'warning' : 'success';
const statusLabel =
property.expiringCount > 0
? t('properties.expiringCount', { count: property.expiringCount })
: t('properties.allValid');
return (
<Card onPress={onPress} style={{ padding: 0, overflow: 'hidden' }}>
<PropertyCover photoUrl={property.photoUrl} type={property.type} height={110} compact />
<View style={{ padding: spacing[4] }}>
<Text style={styles.propName}>{property.name}</Text>
{/* A lone em dash reads as broken data — better to drop the row. */}
{addressLine ? (
<View style={styles.addrRow}>
<Ionicons name="location-outline" size={14} color={colors.slate500} />
<Text style={styles.addr} numberOfLines={1}>
{addressLine}
</Text>
</View>
) : null}
<View style={styles.statsRow}>
<Text style={styles.stat}>{t('properties.docCount', { count: property.documentCount })}</Text>
{property.documentCount > 0 ? (
<>
<View style={styles.dot} />
<Text
style={[
styles.stat,
{
color: statusTone === 'warning' ? colors.warningText : colors.successText,
fontWeight: '600',
},
]}
>
{statusLabel}
</Text>
</>
) : null}
</View>
</View>
</Card>
);
}
// Category pill
export function CategoryPill({ slug, name }: { slug: string; name: string }) {
const c = categoryColor(slug);
return (
<View style={[styles.pill, { backgroundColor: c + '18' }]}>
<View style={[styles.pillDot, { backgroundColor: c }]} />
<Text style={[styles.pillText, { color: c }]}>{name}</Text>
</View>
);
}
// File glyph (PDF / image)
export function FileGlyph({ mimeType, size = 44 }: { mimeType: string; size?: number }) {
const isImage = mimeType.startsWith('image/');
const tag = isImage ? 'IMG' : 'PDF';
const bg = isImage ? colors.teal50 : colors.dangerBg;
const fg = isImage ? colors.teal700 : colors.danger600;
const border = isImage ? colors.teal100 : '#f0c2c9';
return (
<View
style={[styles.glyph, { width: size, height: size, backgroundColor: bg, borderColor: border }]}
>
<Ionicons name={isImage ? 'image-outline' : 'document-text-outline'} size={size * 0.42} color={fg} />
<View style={[styles.glyphTag, { backgroundColor: fg }]}>
<Text style={styles.glyphTagText}>{tag}</Text>
</View>
</View>
);
}
// Expiry badge
export function ExpiryBadge({ document }: { document: Pick<Document, 'expiryStatus' | 'expiryDate'> }) {
const label = expiryLabel(document.expiryStatus, document.expiryDate);
const tone: BadgeTone =
document.expiryStatus === 'expired' ? 'danger' : document.expiryStatus === 'expiring' ? 'warning' : 'success';
return <Badge label={label} tone={tone} />;
}
// Document row
export function DocumentRow({
document,
categoryName,
onPress,
showCategory,
subtitle,
}: {
document: Document;
categoryName?: string;
onPress?: () => void;
showCategory?: boolean;
/** Replaces the default "size · date" meta line (e.g. search results). */
subtitle?: string;
}) {
return (
<Card onPress={onPress} style={styles.docRow}>
<FileGlyph mimeType={document.mimeType} />
<View style={{ flex: 1, marginLeft: spacing[3] }}>
<Text style={styles.docTitle} numberOfLines={1}>
{document.name}
</Text>
{showCategory && categoryName ? (
<View style={{ marginTop: 4, marginBottom: 2, flexDirection: 'row' }}>
<CategoryPill slug={document.category} name={categoryName} />
</View>
) : null}
<Text style={styles.docMeta} numberOfLines={1}>
{subtitle ?? `${formatBytes(document.sizeBytes)} · ${formatDate(document.uploadedAt)}`}
</Text>
</View>
<ExpiryBadge document={document} />
</Card>
);
}
const styles = StyleSheet.create({
propName: { fontSize: fontSize.lg, fontWeight: '700', color: colors.ink },
addrRow: { flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 4 },
addr: { fontSize: fontSize.sm, color: colors.slate500, flexShrink: 1 },
statsRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 10 },
stat: { fontSize: fontSize.sm, color: colors.slate600 },
dot: { width: 3, height: 3, borderRadius: 2, backgroundColor: colors.slate400 },
pill: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
height: 24,
borderRadius: radii.pill,
alignSelf: 'flex-start',
},
pillDot: { width: 7, height: 7, borderRadius: 4 },
pillText: { fontSize: fontSize.xs, fontWeight: '600' },
glyph: { borderRadius: radii.md, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
glyphTag: {
position: 'absolute',
bottom: 4,
right: 4,
paddingHorizontal: 4,
height: 13,
borderRadius: 3,
alignItems: 'center',
justifyContent: 'center',
},
glyphTagText: { color: '#fff', fontSize: 8, fontWeight: '800', letterSpacing: 0.3 },
docRow: { flexDirection: 'row', alignItems: 'center' },
docTitle: { fontSize: fontSize.md, fontWeight: '600', color: colors.ink },
docMeta: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 3 },
});
+394
View File
@@ -0,0 +1,394 @@
import React from 'react';
import {
ActivityIndicator,
Pressable,
StyleProp,
StyleSheet,
Text,
TextInput,
TextInputProps,
TextStyle,
View,
ViewStyle,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useT } from '@/i18n';
import { colors, fontSize, radii, shadow, spacing } from '@/theme';
// Text
type TitleProps = { children: React.ReactNode; style?: StyleProp<TextStyle> };
export function H1({ children, style }: TitleProps) {
return <Text style={[styles.h1, style]}>{children}</Text>;
}
export function H2({ children, style }: TitleProps) {
return <Text style={[styles.h2, style]}>{children}</Text>;
}
export function H3({ children, style }: TitleProps) {
return <Text style={[styles.h3, style]}>{children}</Text>;
}
export function Muted({ children, style }: TitleProps) {
return <Text style={[styles.muted, style]}>{children}</Text>;
}
export function Label({ children, style }: TitleProps) {
return <Text style={[styles.label, style]}>{children}</Text>;
}
// Button
type ButtonVariant = 'primary' | 'ghost' | 'danger';
type ButtonSize = 'md' | 'lg';
interface ButtonProps {
title: string;
onPress?: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
block?: boolean;
disabled?: boolean;
loading?: boolean;
icon?: keyof typeof Ionicons.glyphMap;
iconRight?: keyof typeof Ionicons.glyphMap;
dashed?: boolean;
style?: StyleProp<ViewStyle>;
}
export function Button({
title,
onPress,
variant = 'primary',
size = 'md',
block,
disabled,
loading,
icon,
iconRight,
dashed,
style,
}: ButtonProps) {
const isPrimary = variant === 'primary';
const isDanger = variant === 'danger';
const fg = isPrimary ? colors.onDark : isDanger ? colors.danger600 : colors.teal700;
const bg = isPrimary ? colors.teal600 : 'transparent';
return (
<Pressable
onPress={disabled || loading ? undefined : onPress}
style={({ pressed }) => [
styles.btn,
size === 'lg' && styles.btnLg,
{ backgroundColor: bg },
!isPrimary && styles.btnOutline,
dashed && { borderStyle: 'dashed', borderColor: colors.borderStrong },
block && { alignSelf: 'stretch' },
(disabled || loading) && { opacity: 0.55 },
pressed && { opacity: 0.85 },
style,
]}
>
{loading ? (
<ActivityIndicator color={fg} />
) : (
<>
{icon && <Ionicons name={icon} size={18} color={fg} style={{ marginRight: 8 }} />}
<Text style={[styles.btnText, { color: fg }]}>{title}</Text>
{iconRight && <Ionicons name={iconRight} size={18} color={fg} style={{ marginLeft: 8 }} />}
</>
)}
</Pressable>
);
}
// Card
export function Card({
children,
style,
onPress,
}: {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
onPress?: () => void;
}) {
const content = <View style={[styles.card, style]}>{children}</View>;
if (!onPress) return content;
return (
<Pressable onPress={onPress} style={({ pressed }) => pressed && { opacity: 0.9 }}>
{content}
</Pressable>
);
}
// Badge
export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'teal';
const BADGE_TONES: Record<BadgeTone, { bg: string; fg: string; border: string }> = {
neutral: { bg: colors.surfaceSunken, fg: colors.slate600, border: colors.border },
success: { bg: colors.successBg, fg: colors.successText, border: '#c9e9d5' },
warning: { bg: colors.warningBg, fg: colors.warningText, border: '#f0dcae' },
danger: { bg: colors.dangerBg, fg: colors.dangerText, border: '#f0c2c9' },
info: { bg: colors.infoBg, fg: colors.infoText, border: '#cfe0f5' },
teal: { bg: colors.teal50, fg: colors.teal700, border: colors.teal100 },
};
export function Badge({ label, tone = 'neutral' }: { label: string; tone?: BadgeTone }) {
const t = BADGE_TONES[tone];
return (
<View style={[styles.badge, { backgroundColor: t.bg, borderColor: t.border }]}>
<Text style={[styles.badgeText, { color: t.fg }]}>{label}</Text>
</View>
);
}
// Field + input
export function Field({
label,
optional,
children,
style,
}: {
label: string;
optional?: boolean;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}) {
const t = useT();
return (
<View style={[{ marginBottom: spacing[4] }, style]}>
<Text style={styles.fieldLabel}>
{label}
{optional ? <Text style={styles.optional}> {t('common.optional')}</Text> : null}
</Text>
{children}
</View>
);
}
export function TextField({
icon,
style,
...props
}: TextInputProps & { icon?: keyof typeof Ionicons.glyphMap }) {
return (
<View style={styles.inputGroup}>
{icon && <Ionicons name={icon} size={18} color={colors.slate400} style={{ marginRight: 10 }} />}
<TextInput
placeholderTextColor={colors.textMuted2}
style={[styles.input, style]}
{...props}
/>
</View>
);
}
// Chip / single-select row
export function Chip({
label,
active,
onPress,
count,
}: {
label: string;
active?: boolean;
onPress?: () => void;
count?: number;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.chip, active ? styles.chipActive : styles.chipIdle]}
>
<Text style={[styles.chipText, active && { color: colors.teal700 }]}>{label}</Text>
{count != null && (
<View style={[styles.chipCount, active && { backgroundColor: colors.teal100 }]}>
<Text style={[styles.chipCountText, active && { color: colors.teal700 }]}>{count}</Text>
</View>
)}
</Pressable>
);
}
// Avatar
export function Avatar({ initials, size = 44 }: { initials: string; size?: number }) {
return (
<View style={[styles.avatar, { width: size, height: size, borderRadius: size / 2 }]}>
<Text style={[styles.avatarText, { fontSize: size * 0.36 }]}>{initials}</Text>
</View>
);
}
// Section title, divider, empty, loading
export function SectionTitle({ title, action }: { title: string; action?: React.ReactNode }) {
return (
<View style={styles.sectionTitle}>
<Text style={styles.h4}>{title}</Text>
{action}
</View>
);
}
export function Loading({ label }: { label?: string }) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.teal600} size="large" />
{label ? <Text style={[styles.muted, { marginTop: spacing[3] }]}>{label}</Text> : null}
</View>
);
}
export function EmptyState({
icon = 'file-tray-outline',
title,
subtitle,
action,
}: {
icon?: keyof typeof Ionicons.glyphMap;
title: string;
subtitle?: string;
action?: React.ReactNode;
}) {
return (
<View style={styles.center}>
<View style={styles.emptyIcon}>
<Ionicons name={icon} size={30} color={colors.teal600} />
</View>
<Text style={[styles.h3, { marginTop: spacing[4], textAlign: 'center' }]}>{title}</Text>
{subtitle ? (
<Text style={[styles.muted, { marginTop: 6, textAlign: 'center', maxWidth: 280 }]}>
{subtitle}
</Text>
) : null}
{action ? <View style={{ marginTop: spacing[5] }}>{action}</View> : null}
</View>
);
}
export const IconButton = ({
name,
onPress,
color = colors.ink,
}: {
name: keyof typeof Ionicons.glyphMap;
onPress?: () => void;
color?: string;
}) => (
<Pressable onPress={onPress} hitSlop={10} style={({ pressed }) => [styles.iconBtn, pressed && { opacity: 0.6 }]}>
<Ionicons name={name} size={22} color={color} />
</Pressable>
);
// styles
const styles = StyleSheet.create({
h1: { fontSize: fontSize['4xl'], fontWeight: '700', color: colors.ink, letterSpacing: -0.5 },
h2: { fontSize: fontSize['2xl'], fontWeight: '700', color: colors.ink, letterSpacing: -0.3 },
h3: { fontSize: fontSize.lg, fontWeight: '700', color: colors.ink },
h4: { fontSize: fontSize.md, fontWeight: '700', color: colors.ink },
muted: { fontSize: fontSize.sm, color: colors.textMuted },
label: {
fontSize: fontSize.xs,
fontWeight: '700',
color: colors.slate500,
textTransform: 'uppercase',
letterSpacing: 0.4,
},
btn: {
minHeight: 46,
paddingHorizontal: spacing[5],
borderRadius: radii.md,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
btnLg: { minHeight: 54 },
btnOutline: { borderWidth: 1.5, borderColor: colors.border, backgroundColor: colors.surface },
btnText: { fontSize: fontSize.md, fontWeight: '600' },
card: {
backgroundColor: colors.surface,
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.border,
padding: spacing[4],
...shadow.xs,
},
badge: {
paddingHorizontal: 10,
height: 24,
borderRadius: radii.pill,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'flex-start',
},
badgeText: {
fontSize: fontSize['2xs'],
fontWeight: '700',
letterSpacing: 0.4,
textTransform: 'uppercase',
},
fieldLabel: { fontSize: fontSize.sm, fontWeight: '600', color: colors.slate600, marginBottom: 8 },
optional: { fontSize: fontSize.xs, color: colors.textMuted2, fontWeight: '400' },
inputGroup: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
paddingHorizontal: spacing[4],
minHeight: 50,
},
input: { flex: 1, fontSize: fontSize.md, color: colors.ink, paddingVertical: 12 },
chip: {
flexDirection: 'row',
alignItems: 'center',
height: 36,
paddingHorizontal: 14,
borderRadius: radii.pill,
borderWidth: 1,
},
chipIdle: { backgroundColor: colors.surface, borderColor: colors.border },
chipActive: { backgroundColor: colors.teal50, borderColor: colors.teal300 },
chipText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.slate600 },
chipCount: {
marginLeft: 7,
paddingHorizontal: 7,
height: 18,
borderRadius: 9,
backgroundColor: colors.surfaceSunken,
alignItems: 'center',
justifyContent: 'center',
},
chipCountText: { fontSize: fontSize['2xs'], fontWeight: '700', color: colors.slate500 },
avatar: { backgroundColor: colors.teal600, alignItems: 'center', justifyContent: 'center' },
avatarText: { color: colors.onDark, fontWeight: '700' },
sectionTitle: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing[3],
marginTop: spacing[2],
},
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing[6] },
emptyIcon: {
width: 68,
height: 68,
borderRadius: 20,
backgroundColor: colors.teal50,
alignItems: 'center',
justifyContent: 'center',
},
iconBtn: {
width: 40,
height: 40,
borderRadius: radii.md,
alignItems: 'center',
justifyContent: 'center',
},
});
+357
View File
@@ -0,0 +1,357 @@
export default {
common: {
cancel: 'Cancel',
delete: 'Delete',
retry: 'Retry',
optional: '(optional)',
all: 'All',
seeAll: 'See all',
tryAgain: 'Please try again.',
loading: 'Loading…',
},
tabs: {
home: 'Home',
search: 'Search',
profile: 'Profile',
upload: 'Upload',
scan: 'Scan',
expiring: 'Expiring',
},
expiring: {
title: 'Expiring',
subtitle: { one: '%{count} document needs attention', other: '%{count} documents need attention' },
emptyTitle: 'Nothing expiring',
emptySubtitle: 'All your documents are up to date.',
},
login: {
tagline: 'Every property document, organized and always within reach.',
email: 'Email',
emailPlaceholder: 'you@example.com',
password: 'Password',
passwordPlaceholder: 'Your password',
show: 'Show',
hide: 'Hide',
signIn: 'Sign in',
missingFields: 'Enter your email and password.',
failed: 'Sign in failed. Check your credentials.',
demoHint: 'Demo mode — any credentials sign you in.',
forgot: 'Forgot your password?',
noAccount: 'No account yet?',
trust: 'Encrypted connection · secure cloud storage',
},
register: {
title: 'Create your account',
subtitle: 'One place for every document of every property you own.',
firstName: 'First name',
lastName: 'Last name',
passwordHint: 'At least %{count} characters',
submit: 'Create account',
haveAccount: 'Already have an account?',
badEmail: 'Enter a valid email address.',
shortPassword: 'The password must be at least %{count} characters.',
emailTaken: 'That email is already registered. Try signing in instead.',
},
verify: {
title: 'Check your email',
subtitle: 'We sent a 6-digit code to %{email}. Enter it below to finish.',
code: 'Verification code',
submit: 'Confirm',
resend: 'Send a new code',
resendIn: 'You can request a new code in %{count}s',
invalid: 'That code is not right. Check it and try again.',
expired: 'That code has expired. Request a new one.',
},
forgot: {
title: 'Reset your password',
subtitle: 'Enter your email and we will send you a code to set a new password.',
submit: 'Send code',
},
reset: {
title: 'Set a new password',
newPassword: 'New password',
submit: 'Save password',
doneTitle: 'Password changed',
doneBody: 'You have been signed out everywhere. Sign in with the new password.',
},
editProfile: {
title: 'Personal details',
save: 'Save changes',
phone: 'Phone',
address: 'Address',
emailReadOnly: 'Email cannot be changed here — contact support if you need to.',
failed: 'Could not save your details',
photo: 'Change photo',
},
changePassword: {
title: 'Change password',
current: 'Current password',
next: 'New password',
submit: 'Change password',
wrongCurrent: 'That is not your current password.',
doneTitle: 'Password changed',
doneBody: 'Other devices have been signed out.',
failed: 'Could not change the password',
},
properties: {
title: 'My Properties',
count: { one: '%{count} property', other: '%{count} properties' },
loading: 'Loading properties…',
loadError: "Couldn't load properties",
searchPlaceholder: 'Search documents by name or category…',
add: 'Add property',
emptyTitle: 'No properties yet',
emptySubtitle: 'Add your first property to start organizing its documents.',
docCount: { one: '%{count} document', other: '%{count} documents' },
expiringCount: '%{count} expiring',
allValid: 'All valid',
counting: 'Counting documents…',
noPhoto: 'No photo',
},
property: {
fallbackTitle: 'Property',
addDocument: 'Add document',
needAttention: {
one: '%{count} document needs attention',
other: '%{count} documents need attention',
},
categories: 'Categories',
folders: { one: '%{count} folder', other: '%{count} folders' },
noDocuments: 'No documents yet. Tap “Add document” to start.',
recent: 'Recent documents',
addAddress: 'Add an address',
noRecent: 'Recently uploaded documents will show up here.',
},
addProperty: {
title: 'Add property',
name: 'Property name',
namePlaceholder: 'e.g. Villa Bellavista',
type: 'Type',
address: 'Address',
addressPlaceholder: 'Street, number',
zip: 'ZIP',
city: 'City',
description: 'Description',
descriptionPlaceholder: 'Notes about this property…',
save: 'Save property',
nameRequired: 'Name required',
nameRequiredBody: 'Give the property a name.',
saveFailed: 'Could not save',
addPhoto: 'Add cover photo',
photoSourceTitle: 'Property photo',
takePhoto: 'Take a photo',
fromGallery: 'Choose from gallery',
changePhoto: 'Change photo',
photoFailed: 'Property saved, but the photo failed to upload',
},
location: {
title: 'Location',
field: 'Map location',
set: 'Pin on map',
change: 'Change location',
notSet: 'No location set',
noPin: 'Tap the map to drop a pin',
tapHint: 'Tap the map to place the pin',
fromAddress: 'Find from the address above',
confirm: 'Use this location',
notFound: 'Address not found',
notFoundBody: 'Try a more specific address, or drop the pin by hand.',
permission: 'Allow location access to use your current position.',
gpsFailed: "Couldn't get your position",
servicesOff: 'Turn on Location Services',
servicesOffBody:
'Location Services are off for this device. Turn them on in Settings → Privacy & Security → Location Services, then try again.',
fillTitle: 'Use this address?',
fillBody: 'The map knows this place as:\n\n%{address}\n\nReplace the address fields with it?',
fillApply: 'Replace',
fillKeep: 'Keep mine',
},
editProperty: {
title: 'Edit property',
save: 'Save changes',
action: 'Edit',
delete: 'Delete property',
deleteTitle: 'Delete this property?',
deleteBody: '“%{name}” will be permanently deleted. This cannot be undone.',
deleteBodyWithDocs:
'“%{name}” and its %{count} document(s) will be permanently deleted. This cannot be undone.',
deleteFailed: 'Could not delete the property',
},
editDocument: {
title: 'Edit document',
save: 'Save changes',
name: 'Document name',
namePlaceholder: 'Leave empty to use the default name',
note: 'Note',
notePlaceholder: 'Anything worth remembering about this document…',
noExpiry: 'No expiry date',
failed: 'Could not save',
hint: 'These details are updated without touching the stored file.',
file: 'File',
replace: 'Replace file',
replaceHint: 'Re-scan, pick another file or take a new photo — the document keeps its history.',
replacing: 'Replacing…',
replaced: 'File replaced',
replaceFailed: 'Could not replace the file',
},
propertyTypes: {
apartment: 'Apartment',
house: 'House',
villa: 'Villa',
commercial: 'Commercial',
land: 'Land',
building: 'Building',
},
documents: {
allTitle: 'All documents',
filterAttention: 'Needs attention',
filterValid: 'Valid',
filterExpiring: 'Expiring',
filterExpired: 'Expired',
emptyTitle: 'Nothing here',
emptySubtitle: 'No documents match this filter.',
},
document: {
title: 'Document',
expired: 'Expired',
expiringSoon: 'Expiring soon',
expiresOn: 'Expires %{date}.',
checkExpiry: 'Check the expiry date.',
loadingFile: 'Loading file…',
pages: { one: '%{count} page', other: '%{count} pages' },
uploaded: 'Uploaded',
expires: 'Expires',
fileSize: 'File size',
format: 'Format',
formatImage: 'Image',
formatPdf: 'PDF',
share: 'Save / Share',
openOnPhone: 'Open on my phone',
deleteTitle: 'Delete document',
deleteBody: 'This permanently removes the file. Continue?',
cannotOpen: 'Cannot open',
cannotOpenBody: 'The file could not be opened.',
cannotSave: 'Cannot save',
cannotSaveBody: 'The file could not be shared.',
pdfCard: 'PDF document',
pdfTapToOpen: 'Tap to open on your phone',
pdfPreparing: 'Preparing…',
},
upload: {
title: 'Add document',
source: 'Source',
scan: 'Scan',
scanning: 'Scanning…',
chooseFile: 'Choose file',
gallery: 'Gallery',
scanHint: 'Scan photographs multiple pages and combines them into a single PDF.',
readyToUpload: 'ready to upload',
name: 'Document name',
namePlaceholder: 'e.g. Energy Certificate 2026',
property: 'Property',
selectProperty: 'Select a property',
category: 'Category',
requirement: 'Document type',
selectRequirement: 'Choose a document type',
requirementHint: 'Picking the right type keeps the document where the portal expects it.',
noRequirementTitle: 'Choose a document type',
noRequirementBody: 'Pick which type of document this is, so it is filed correctly.',
expiry: 'Expiration date',
submit: 'Upload document',
scannedName: 'Scanned document',
photoName: 'Photo document',
noFileTitle: 'Add a file',
noFileBody: 'Choose, photograph or scan a document first.',
nameRequired: 'Name required',
nameRequiredBody: 'Give the document a name.',
noPropertyTitle: 'Select a property',
noPropertyBody: 'Pick which property this belongs to.',
failed: 'Upload failed',
scannerUnavailable: 'Scanner unavailable',
scannerUnavailableBody: 'Could not open the scanner.',
permissionTitle: 'Permission needed',
photoPermission: 'Allow photo access to attach an image.',
},
profile: {
title: 'Profile',
account: 'Account',
legal: 'Legal',
personalDetails: 'Personal details',
personalDetailsSub: 'Name, email, phone',
security: 'Password & security',
securitySub: 'Change password',
biometrics: 'Biometric sign-in',
biometricsSub: 'Faster, secure login',
signOut: 'Sign out',
demoMode: 'Demo mode · running on mock data',
language: 'Language',
languageSystem: 'System',
languageSystemSub: 'Follow the phone’s language',
dangerZone: 'Danger zone',
deleteAccount: 'Delete my account',
deleteAccountSub: 'Permanently erases your account, properties and documents',
deleteConfirmTitle: 'Delete your account?',
deleteConfirmBody:
'This permanently deletes your account together with every property and document you stored. It cannot be undone.',
deleteConfirmCta: 'Delete everything',
deleteFailed: 'Could not delete the account',
privacy: 'Privacy policy',
terms: 'Terms of use',
},
permissions: {
primerTitle: 'Camera access',
primerBody:
'CasaDoc uses the camera to scan documents into PDF. Nothing is uploaded until you tap Upload.',
continue: 'Continue',
notNow: 'Not now',
blockedTitle: 'Permission is switched off',
photosBody:
'Photo access is off. Tap “Open settings”, then choose Permissions › Photos and music (or Files) and allow it.',
cameraBody:
'Camera access is off. Tap “Open settings”, then choose Permissions › Camera and allow it.',
locationBody:
'Location access is off, and the address lookup needs it. Tap “Open settings”, then choose Permissions › Location and allow it. You can still place the pin by hand.',
openSettings: 'Open settings',
},
network: {
offline: 'No connection — showing saved data',
},
files: {
noFile: 'This document has no file attached.',
missingOnServer: 'The file is missing on the server (404).',
downloadFailed: 'Could not download the file (HTTP %{status}).',
noViewer: 'No app available to open this file.',
sharingUnavailable: 'Sharing is not available on this device.',
scannerNeedsDevBuild:
'Native document scanner is unavailable. Run a Dev Client / EAS build (not Expo Go).',
},
expiry: {
valid: 'Valid',
expired: 'Expired',
expiring: 'Expiring',
days: '%{count} days',
},
};
+116
View File
@@ -0,0 +1,116 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { I18n } from 'i18n-js';
import { getLocales } from 'expo-localization';
import * as SecureStore from 'expo-secure-store';
import en from './en';
import it from './it';
// English and Italian. Follows the phone's locale unless overridden in Profile.
export type AppLanguage = 'en' | 'it';
/** 'system' means follow the phone. */
export type LanguagePreference = AppLanguage | 'system';
export const SUPPORTED_LANGUAGES: AppLanguage[] = ['en', 'it'];
const PREF_KEY = 'casadoc.language';
const i18n = new I18n({ en, it });
i18n.defaultLocale = 'en';
i18n.enableFallback = true;
/** Resolve the phone's locale to a language we actually ship. */
export function deviceLanguage(): AppLanguage {
const tags = getLocales();
for (const l of tags) {
const code = (l.languageCode ?? '').toLowerCase();
if (code === 'it') return 'it';
if (code === 'en') return 'en';
}
return 'en'; // neither Italian nor English → English
}
function resolve(pref: LanguagePreference): AppLanguage {
return pref === 'system' ? deviceLanguage() : pref;
}
// Applied immediately, so translation calls before the provider mounts work.
i18n.locale = deviceLanguage();
/** For non-React code; inside components use `useT()` so it re-renders. */
export function t(key: string, options?: Record<string, unknown>): string {
return i18n.t(key, options as any);
}
/** Current BCP-47-ish locale, for Intl date/number formatting. */
export function currentLocale(): string {
return i18n.locale === 'it' ? 'it-IT' : 'en-GB';
}
interface I18nState {
language: AppLanguage;
preference: LanguagePreference;
setPreference: (pref: LanguagePreference) => void;
t: (key: string, options?: Record<string, unknown>) => string;
}
const I18nContext = createContext<I18nState | undefined>(undefined);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [preference, setPreferenceState] = useState<LanguagePreference>('system');
const [ready, setReady] = useState(false);
useEffect(() => {
(async () => {
try {
const saved = await SecureStore.getItemAsync(PREF_KEY);
if (saved === 'en' || saved === 'it' || saved === 'system') {
setPreferenceState(saved);
i18n.locale = resolve(saved);
}
} catch {
// fall back to the system language
} finally {
setReady(true);
}
})();
}, []);
const setPreference = useCallback((pref: LanguagePreference) => {
setPreferenceState(pref);
i18n.locale = resolve(pref);
SecureStore.setItemAsync(PREF_KEY, pref).catch(() => {
// a lost preference only means we fall back to the phone's language
});
}, []);
const language = resolve(preference);
const value = useMemo<I18nState>(
() => ({
language,
preference,
setPreference,
t: (key: string, options?: Record<string, unknown>) => {
i18n.locale = language;
return i18n.t(key, options as any);
},
}),
[language, preference, setPreference],
);
// Avoids a flash of the wrong language before the preference loads.
if (!ready) return null;
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nState {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error('useI18n must be used within <I18nProvider>');
return ctx;
}
export function useT() {
return useI18n().t;
}
+360
View File
@@ -0,0 +1,360 @@
export default {
common: {
cancel: 'Annulla',
delete: 'Elimina',
retry: 'Riprova',
optional: '(facoltativo)',
all: 'Tutti',
seeAll: 'Vedi tutti',
tryAgain: 'Riprova per favore.',
loading: 'Caricamento…',
},
tabs: {
home: 'Home',
search: 'Cerca',
profile: 'Profilo',
upload: 'Carica',
scan: 'Scansiona',
expiring: 'Scadenze',
},
expiring: {
title: 'Scadenze',
subtitle: {
one: '%{count} documento richiede attenzione',
other: '%{count} documenti richiedono attenzione',
},
emptyTitle: 'Nessuna scadenza',
emptySubtitle: 'Tutti i documenti sono in regola.',
},
login: {
tagline: 'Tutti i documenti dei tuoi immobili, ordinati e sempre a portata di mano.',
email: 'Email',
emailPlaceholder: 'tu@esempio.com',
password: 'Password',
passwordPlaceholder: 'La tua password',
show: 'Mostra',
hide: 'Nascondi',
signIn: 'Accedi',
missingFields: 'Inserisci email e password.',
failed: 'Accesso non riuscito. Controlla le credenziali.',
demoHint: 'Modalità demo — qualsiasi credenziale consente l’accesso.',
forgot: 'Password dimenticata?',
noAccount: 'Non hai un account?',
trust: 'Connessione cifrata · archiviazione sicura nel cloud',
},
register: {
title: 'Crea il tuo account',
subtitle: 'Un unico posto per tutti i documenti dei tuoi immobili.',
firstName: 'Nome',
lastName: 'Cognome',
passwordHint: 'Almeno %{count} caratteri',
submit: 'Crea account',
haveAccount: 'Hai già un account?',
badEmail: 'Inserisci un indirizzo email valido.',
shortPassword: 'La password deve avere almeno %{count} caratteri.',
emailTaken: 'Questa email è già registrata. Prova ad accedere.',
},
verify: {
title: 'Controlla la tua email',
subtitle: 'Abbiamo inviato un codice di 6 cifre a %{email}. Inseriscilo qui sotto.',
code: 'Codice di verifica',
submit: 'Conferma',
resend: 'Invia un nuovo codice',
resendIn: 'Puoi richiedere un nuovo codice tra %{count}s',
invalid: 'Il codice non è corretto. Controllalo e riprova.',
expired: 'Il codice è scaduto. Richiedine uno nuovo.',
},
forgot: {
title: 'Reimposta la password',
subtitle: 'Inserisci la tua email: ti invieremo un codice per impostare una nuova password.',
submit: 'Invia codice',
},
reset: {
title: 'Imposta una nuova password',
newPassword: 'Nuova password',
submit: 'Salva password',
doneTitle: 'Password modificata',
doneBody: 'Sei stato disconnesso ovunque. Accedi con la nuova password.',
},
editProfile: {
title: 'Dati personali',
save: 'Salva modifiche',
phone: 'Telefono',
address: 'Indirizzo',
emailReadOnly: 'L’email non si può modificare qui — contatta l’assistenza se necessario.',
failed: 'Impossibile salvare i dati',
photo: 'Cambia foto',
},
changePassword: {
title: 'Cambia password',
current: 'Password attuale',
next: 'Nuova password',
submit: 'Cambia password',
wrongCurrent: 'Questa non è la tua password attuale.',
doneTitle: 'Password modificata',
doneBody: 'Gli altri dispositivi sono stati disconnessi.',
failed: 'Impossibile cambiare la password',
},
properties: {
title: 'I miei immobili',
count: { one: '%{count} immobile', other: '%{count} immobili' },
loading: 'Caricamento immobili…',
loadError: 'Impossibile caricare gli immobili',
searchPlaceholder: 'Cerca documenti per nome o categoria…',
add: 'Aggiungi immobile',
emptyTitle: 'Nessun immobile',
emptySubtitle: 'Aggiungi il primo immobile per iniziare a organizzare i documenti.',
docCount: { one: '%{count} documento', other: '%{count} documenti' },
expiringCount: '%{count} in scadenza',
allValid: 'Tutti validi',
counting: 'Conteggio documenti…',
noPhoto: 'Nessuna foto',
},
property: {
fallbackTitle: 'Immobile',
addDocument: 'Aggiungi documento',
needAttention: {
one: '%{count} documento richiede attenzione',
other: '%{count} documenti richiedono attenzione',
},
categories: 'Categorie',
folders: { one: '%{count} cartella', other: '%{count} cartelle' },
noDocuments: 'Ancora nessun documento. Tocca “Aggiungi documento” per iniziare.',
recent: 'Documenti recenti',
addAddress: 'Aggiungi un indirizzo',
noRecent: 'I documenti caricati di recente compariranno qui.',
},
addProperty: {
title: 'Aggiungi immobile',
name: 'Nome immobile',
namePlaceholder: 'es. Villa Bellavista',
type: 'Tipo',
address: 'Indirizzo',
addressPlaceholder: 'Via, numero',
zip: 'CAP',
city: 'Città',
description: 'Descrizione',
descriptionPlaceholder: 'Note su questo immobile…',
save: 'Salva immobile',
nameRequired: 'Nome obbligatorio',
nameRequiredBody: 'Assegna un nome all’immobile.',
saveFailed: 'Impossibile salvare',
addPhoto: 'Aggiungi foto di copertina',
photoSourceTitle: 'Foto dell’immobile',
takePhoto: 'Scatta una foto',
fromGallery: 'Scegli dalla galleria',
changePhoto: 'Cambia foto',
photoFailed: 'Immobile salvato, ma il caricamento della foto non è riuscito',
},
location: {
title: 'Posizione',
field: 'Posizione sulla mappa',
set: 'Indica sulla mappa',
change: 'Cambia posizione',
notSet: 'Posizione non impostata',
noPin: 'Tocca la mappa per posizionare il segnaposto',
tapHint: 'Tocca la mappa per posizionare il segnaposto',
fromAddress: 'Trova dall’indirizzo sopra',
confirm: 'Usa questa posizione',
notFound: 'Indirizzo non trovato',
notFoundBody: 'Prova un indirizzo più preciso, oppure posiziona il segnaposto a mano.',
permission: 'Consenti l’accesso alla posizione per usare quella attuale.',
gpsFailed: 'Impossibile ottenere la posizione',
servicesOff: 'Attiva i Servizi di localizzazione',
servicesOffBody:
'I Servizi di localizzazione sono disattivati su questo dispositivo. Attivali in Impostazioni → Privacy e sicurezza → Servizi di localizzazione, poi riprova.',
fillTitle: 'Usare questo indirizzo?',
fillBody: 'La mappa identifica il luogo come:\n\n%{address}\n\nSostituire i campi dell’indirizzo?',
fillApply: 'Sostituisci',
fillKeep: 'Mantieni i miei',
},
editProperty: {
title: 'Modifica immobile',
save: 'Salva modifiche',
action: 'Modifica',
delete: 'Elimina immobile',
deleteTitle: 'Eliminare questo immobile?',
deleteBody: '“%{name}” verrà eliminato definitivamente. L’operazione è irreversibile.',
deleteBodyWithDocs:
'“%{name}” e i suoi %{count} documenti verranno eliminati definitivamente. L’operazione è irreversibile.',
deleteFailed: 'Impossibile eliminare l’immobile',
},
editDocument: {
title: 'Modifica documento',
save: 'Salva modifiche',
name: 'Nome documento',
namePlaceholder: 'Lascia vuoto per usare il nome predefinito',
note: 'Nota',
notePlaceholder: 'Qualcosa da ricordare su questo documento…',
noExpiry: 'Nessuna scadenza',
failed: 'Impossibile salvare',
hint: 'Questi dati vengono aggiornati senza toccare il file salvato.',
file: 'File',
replace: 'Sostituisci file',
replaceHint: 'Rifai la scansione, scegli un altro file o scatta una nuova foto — il documento mantiene la sua cronologia.',
replacing: 'Sostituzione…',
replaced: 'File sostituito',
replaceFailed: 'Impossibile sostituire il file',
},
propertyTypes: {
apartment: 'Appartamento',
house: 'Casa',
villa: 'Villa',
commercial: 'Commerciale',
land: 'Terreno',
building: 'Edificio',
},
documents: {
allTitle: 'Tutti i documenti',
filterAttention: 'Da controllare',
filterValid: 'Validi',
filterExpiring: 'In scadenza',
filterExpired: 'Scaduti',
emptyTitle: 'Nessun risultato',
emptySubtitle: 'Nessun documento corrisponde a questo filtro.',
},
document: {
title: 'Documento',
expired: 'Scaduto',
expiringSoon: 'In scadenza',
expiresOn: 'Scade il %{date}.',
checkExpiry: 'Controlla la data di scadenza.',
loadingFile: 'Caricamento file…',
pages: { one: '%{count} pagina', other: '%{count} pagine' },
uploaded: 'Caricato',
expires: 'Scadenza',
fileSize: 'Dimensione',
format: 'Formato',
formatImage: 'Immagine',
formatPdf: 'PDF',
share: 'Salva / Condividi',
openOnPhone: 'Apri sul telefono',
deleteTitle: 'Elimina documento',
deleteBody: 'Il file verrà rimosso definitivamente. Continuare?',
cannotOpen: 'Impossibile aprire',
cannotOpenBody: 'Non è stato possibile aprire il file.',
cannotSave: 'Impossibile salvare',
cannotSaveBody: 'Non è stato possibile condividere il file.',
pdfCard: 'Documento PDF',
pdfTapToOpen: 'Tocca per aprire sul telefono',
pdfPreparing: 'Preparazione…',
},
upload: {
title: 'Aggiungi documento',
source: 'Origine',
scan: 'Scansiona',
scanning: 'Scansione…',
chooseFile: 'Scegli file',
gallery: 'Galleria',
scanHint: 'La scansione fotografa più pagine e le unisce in un unico PDF.',
readyToUpload: 'pronto per il caricamento',
name: 'Nome documento',
namePlaceholder: 'es. Attestato energetico 2026',
property: 'Immobile',
selectProperty: 'Seleziona un immobile',
category: 'Categoria',
requirement: 'Tipo di documento',
selectRequirement: 'Scegli il tipo di documento',
requirementHint: 'Il tipo corretto fa sì che il documento finisca dove il portale se lo aspetta.',
noRequirementTitle: 'Scegli il tipo di documento',
noRequirementBody: 'Indica di che tipo di documento si tratta, così viene archiviato correttamente.',
expiry: 'Data di scadenza',
submit: 'Carica documento',
scannedName: 'Documento scansionato',
photoName: 'Documento fotografato',
noFileTitle: 'Aggiungi un file',
noFileBody: 'Scegli, fotografa o scansiona prima un documento.',
nameRequired: 'Nome obbligatorio',
nameRequiredBody: 'Assegna un nome al documento.',
noPropertyTitle: 'Seleziona un immobile',
noPropertyBody: 'Scegli a quale immobile appartiene.',
failed: 'Caricamento non riuscito',
scannerUnavailable: 'Scanner non disponibile',
scannerUnavailableBody: 'Non è stato possibile aprire lo scanner.',
permissionTitle: 'Autorizzazione necessaria',
photoPermission: 'Consenti l’accesso alle foto per allegare un’immagine.',
},
profile: {
title: 'Profilo',
account: 'Account',
legal: 'Note legali',
personalDetails: 'Dati personali',
personalDetailsSub: 'Nome, email, telefono',
security: 'Password e sicurezza',
securitySub: 'Cambia password',
biometrics: 'Accesso biometrico',
biometricsSub: 'Accesso più rapido e sicuro',
signOut: 'Esci',
demoMode: 'Modalità demo · dati simulati',
language: 'Lingua',
languageSystem: 'Sistema',
languageSystemSub: 'Segui la lingua del telefono',
dangerZone: 'Zona pericolosa',
deleteAccount: 'Elimina il mio account',
deleteAccountSub: 'Cancella definitivamente account, immobili e documenti',
deleteConfirmTitle: 'Eliminare l’account?',
deleteConfirmBody:
'L’account verrà eliminato definitivamente insieme a tutti gli immobili e i documenti salvati. L’operazione è irreversibile.',
deleteConfirmCta: 'Elimina tutto',
deleteFailed: 'Impossibile eliminare l’account',
privacy: 'Informativa sulla privacy',
terms: 'Termini di utilizzo',
},
permissions: {
primerTitle: 'Accesso alla fotocamera',
primerBody:
'CasaDoc usa la fotocamera per scansionare i documenti in PDF. Nulla viene caricato finché non tocchi Carica.',
continue: 'Continua',
notNow: 'Non ora',
blockedTitle: 'Autorizzazione disattivata',
photosBody:
'L’accesso alle foto è disattivato. Tocca “Apri impostazioni”, poi scegli Autorizzazioni › Foto e contenuti multimediali (o File) e consenti l’accesso.',
cameraBody:
'L’accesso alla fotocamera è disattivato. Tocca “Apri impostazioni”, poi scegli Autorizzazioni › Fotocamera e consenti l’accesso.',
locationBody:
'L’accesso alla posizione è disattivato e la ricerca dell’indirizzo lo richiede. Tocca “Apri impostazioni”, poi Autorizzazioni › Posizione e consenti. Puoi comunque posizionare il segnaposto a mano.',
openSettings: 'Apri impostazioni',
},
network: {
offline: 'Nessuna connessione — dati salvati',
},
files: {
noFile: 'Questo documento non ha un file allegato.',
missingOnServer: 'Il file non è presente sul server (404).',
downloadFailed: 'Impossibile scaricare il file (HTTP %{status}).',
noViewer: 'Nessuna app disponibile per aprire questo file.',
sharingUnavailable: 'La condivisione non è disponibile su questo dispositivo.',
scannerNeedsDevBuild:
'Lo scanner nativo non è disponibile. Usa una build Dev Client / EAS (non Expo Go).',
},
expiry: {
valid: 'Valido',
expired: 'Scaduto',
expiring: 'In scadenza',
days: '%{count} giorni',
},
};
+18
View File
@@ -0,0 +1,18 @@
import { api, Document } from '@/api';
/** A document plus the name of the property it belongs to. */
export interface DocWithProperty extends Document {
propertyName: string;
}
/** Shared by Search and Expiring under the 'all-documents' key, so both hit one cache. */
export async function fetchAllDocuments(): Promise<DocWithProperty[]> {
const properties = await api.properties.list();
const perProperty = await Promise.all(
properties.map(async (p) => {
const docs = await api.documents.listByProperty(p.id);
return docs.map((d) => ({ ...d, propertyName: p.name }));
}),
);
return perProperty.flat();
}
+109
View File
@@ -0,0 +1,109 @@
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { Document, getAuthToken } from '@/api';
import { t } from '@/i18n';
// `document-file.php` is bearer-authenticated, so its URL cannot be handed to
// an <Image> or a viewer directly: the request would arrive without the header
// and get a 401. Files are downloaded here and used from the local copy.
const CACHE_DIR = `${FileSystem.cacheDirectory}documents/`;
async function ensureCacheDir(): Promise<void> {
const info = await FileSystem.getInfoAsync(CACHE_DIR);
if (!info.exists) {
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
}
}
function extensionFor(doc: Document): string {
if (doc.mimeType === 'application/pdf') return 'pdf';
if (doc.mimeType === 'image/png') return 'png';
if (doc.mimeType === 'image/heic') return 'heic';
if (doc.mimeType.startsWith('image/')) return 'jpg';
return 'bin';
}
function safeName(doc: Document): string {
const base = doc.name.replace(/[^\w\-]+/g, '_').replace(/^_+|_+$/g, '') || 'document';
return `${doc.id}-${base}.${extensionFor(doc)}`;
}
/** Downloads on first use, reuses the cached copy afterwards. */
export async function getLocalFile(doc: Document): Promise<string> {
if (!doc.fileUri) {
throw new Error(t('files.noFile'));
}
// Scans and picked files are already local — nothing to download.
if (doc.fileUri.startsWith('file://') || doc.fileUri.startsWith('/')) {
return doc.fileUri;
}
await ensureCacheDir();
const target = `${CACHE_DIR}${safeName(doc)}`;
const existing = await FileSystem.getInfoAsync(target);
if (existing.exists && existing.size > 0) {
return target;
}
const token = getAuthToken();
const result = await FileSystem.downloadAsync(doc.fileUri, target, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (result.status === 404) {
// The row exists but the file is missing on disk.
await FileSystem.deleteAsync(target, { idempotent: true });
throw new Error(t('files.missingOnServer'));
}
if (result.status < 200 || result.status >= 300) {
await FileSystem.deleteAsync(target, { idempotent: true });
throw new Error(t('files.downloadFailed', { status: result.status }));
}
return result.uri;
}
/** Call after replacing the file on the server, or the stale copy is shown. */
export async function clearCachedFile(doc: Document): Promise<void> {
await FileSystem.deleteAsync(`${CACHE_DIR}${safeName(doc)}`, { idempotent: true });
}
/** ACTION_VIEW intent on Android, share sheet elsewhere. */
export async function openInSystemViewer(doc: Document): Promise<void> {
const localUri = await getLocalFile(doc);
if (Platform.OS === 'android') {
// Android cannot read another app's file:// URI; it needs a content:// one.
const contentUri = await FileSystem.getContentUriAsync(localUri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
type: doc.mimeType,
});
return;
}
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(localUri, { mimeType: doc.mimeType, UTI: 'public.data' });
return;
}
throw new Error(t('files.noViewer'));
}
export async function shareDocument(doc: Document): Promise<void> {
const localUri = await getLocalFile(doc);
if (!(await Sharing.isAvailableAsync())) {
throw new Error(t('files.sharingUnavailable'));
}
await Sharing.shareAsync(localUri, {
mimeType: doc.mimeType,
dialogTitle: doc.name,
UTI: doc.mimeType === 'application/pdf' ? 'com.adobe.pdf' : 'public.image',
});
}
+43
View File
@@ -0,0 +1,43 @@
import { currentLocale, t } from '@/i18n';
export function formatBytes(bytes?: number | null): string {
if (!bytes || bytes <= 0) return '—';
const mb = bytes / (1024 * 1024);
if (mb >= 1) return `${mb.toFixed(1)} MB`;
const kb = bytes / 1024;
return `${Math.max(1, Math.round(kb))} KB`;
}
/** Locale-aware short date, e.g. "12 Jun 2026" / "12 giu 2026". */
export function formatDate(iso?: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
try {
return d.toLocaleDateString(currentLocale(), {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch {
return d.toISOString().slice(0, 10);
}
}
/** Whole days from now until `iso` (negative = past). */
export function daysUntil(iso?: string | null): number | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return Math.ceil((d.getTime() - Date.now()) / 86_400_000);
}
/** Short human expiry label, e.g. "24 days", "Expired", "Valid". */
export function expiryLabel(status?: string | null, expiryDate?: string | null): string {
if (status === 'expired') return t('expiry.expired');
if (status === 'expiring') {
const days = daysUntil(expiryDate);
return days != null && days >= 0 ? t('expiry.days', { count: days }) : t('expiry.expiring');
}
return t('expiry.valid');
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';
// Network state, wired into React Query so it holds requests while offline
// instead of failing them into empty states.
// `isInternetReachable` is null while probing; treat unknown as online.
function isOnline(state: NetInfoState): boolean {
return !!state.isConnected && state.isInternetReachable !== false;
}
/** Call once at startup, before the query client is used. */
export function startNetworkWatcher(): void {
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => setOnline(isOnline(state))),
);
}
/** Subscribe to connectivity in a component (for the offline banner). */
export function useIsOnline(): boolean {
const [online, setOnline] = useState(true);
useEffect(() => {
NetInfo.fetch().then((state) => setOnline(isOnline(state)));
return NetInfo.addEventListener((state) => setOnline(isOnline(state)));
}, []);
return online;
}
+25
View File
@@ -0,0 +1,25 @@
// expo-router can't return a value from a pushed screen, so the map picker
// parks its result here and the form claims it on focus.
export interface PickedLocation {
latitude: number;
longitude: number;
/** Reverse-geocoded, so the form can offer to fill the address. */
address?: string | null;
postalCode?: string | null;
city?: string | null;
country?: string | null;
}
let pending: PickedLocation | null = null;
export function setPendingLocation(value: PickedLocation): void {
pending = value;
}
/** Returns the pending pick and clears it, so it is applied once. */
export function takePendingLocation(): PickedLocation | null {
const value = pending;
pending = null;
return value;
}
+57
View File
@@ -0,0 +1,57 @@
import { Alert, Linking, Platform } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import { t } from '@/i18n';
// Only the camera is ever requested; the gallery goes through the system photo
// picker, which needs no permission.
export function openPermissionSettings(bodyKey: string): void {
Alert.alert(t('permissions.blockedTitle'), t(bodyKey), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('permissions.openSettings'), onPress: () => void Linking.openSettings() },
]);
}
/** Denied for good: a retry can't fix it, only the settings screen can. */
export async function isCameraBlocked(): Promise<boolean> {
const status = await ImagePicker.getCameraPermissionsAsync();
return !status.granted && !status.canAskAgain;
}
/** Pass ask=true only when the user asked for something that needs it. */
export async function ensureLocationPermission(ask: boolean): Promise<boolean> {
let status = await Location.getForegroundPermissionsAsync();
if (!status.granted && ask && status.canAskAgain) {
status = await Location.requestForegroundPermissionsAsync();
}
return status.granted;
}
// Android routes geocoding through the location service and rejects it without
// the permission; iOS uses CLGeocoder, which needs none.
export async function ensureGeocodePermission(ask: boolean): Promise<boolean> {
if (Platform.OS !== 'android') return true;
return ensureLocationPermission(ask);
}
let askedThisSession = false;
/** Safe on every mount: asks at most once per session. */
export async function primeDocumentPermissions(): Promise<void> {
if (askedThisSession) return;
askedThisSession = true;
const camera = await ImagePicker.getCameraPermissionsAsync();
if (camera.granted || !camera.canAskAgain) return;
// A system dialog out of nowhere gets denied far more often.
await new Promise<void>((resolve) => {
Alert.alert(t('permissions.primerTitle'), t('permissions.primerBody'), [
{ text: t('permissions.notNow'), style: 'cancel', onPress: () => resolve() },
{ text: t('permissions.continue'), onPress: () => resolve() },
]);
});
await ImagePicker.requestCameraPermissionsAsync();
}
+69
View File
@@ -0,0 +1,69 @@
import * as ImagePicker from 'expo-image-picker';
import { LocalFile } from '@/api';
import { openPermissionSettings } from './permissions';
// The library picker asks for no permission on purpose: it opens the system
// photo picker, which returns a file the user explicitly chose. Gating it on a
// permission means one past denial stops a picker that would work fine.
export interface PickImageOptions {
/** Crop UI, used for property cover photos. */
allowsEditing?: boolean;
aspect?: [number, number];
quality?: number;
fallbackName?: string;
}
/** Needs the camera permission, unlike the library picker below. */
export async function takePhotoWithCamera(options: PickImageOptions = {}): Promise<LocalFile | null> {
let status = await ImagePicker.getCameraPermissionsAsync();
if (!status.granted && status.canAskAgain) {
status = await ImagePicker.requestCameraPermissionsAsync();
}
if (!status.granted) {
openPermissionSettings('permissions.cameraBody');
return null;
}
const result = await ImagePicker.launchCameraAsync({
quality: options.quality ?? 0.85,
allowsEditing: options.allowsEditing,
aspect: options.aspect,
});
if (result.canceled || !result.assets?.length) return null;
const a = result.assets[0];
return {
uri: a.uri,
name: a.fileName ?? `${options.fallbackName ?? 'photo'}-${Date.now()}.jpg`,
mimeType: a.mimeType ?? 'image/jpeg',
sizeBytes: a.fileSize ?? 0,
};
}
/** Returns the chosen image, or null if the user cancelled. */
export async function pickImageFromLibrary(options: PickImageOptions = {}): Promise<LocalFile | null> {
let result: ImagePicker.ImagePickerResult;
try {
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: options.quality ?? 0.85,
allowsEditing: options.allowsEditing,
aspect: options.aspect,
});
} catch (e: any) {
// The only user-fixable cause is a permission the OS still insists on.
openPermissionSettings('permissions.photosBody');
return null;
}
if (result.canceled || !result.assets?.length) return null;
const a = result.assets[0];
return {
uri: a.uri,
name: a.fileName ?? `${options.fallbackName ?? 'image'}-${Date.now()}.jpg`,
mimeType: a.mimeType ?? 'image/jpeg',
sizeBytes: a.fileSize ?? 0,
};
}
+38
View File
@@ -0,0 +1,38 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { QueryClient } from '@tanstack/react-query';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
// Query client with an on-disk cache, so a cold start offline still shows the
// last known data.
export const PERSIST_KEY = 'casadoc.query-cache';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
// Well above staleTime so entries survive a restart.
gcTime: 7 * 24 * 60 * 60 * 1000,
},
},
});
export const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: PERSIST_KEY,
throttleTime: 2_000,
});
/**
* Must run on sign-out and account deletion: AsyncStorage is not encrypted, and
* the cache holds one user's properties and documents.
*/
export async function clearPersistedCache(): Promise<void> {
queryClient.clear();
try {
await persister.removeClient();
} catch {
// Best-effort; never block sign-out on it.
}
}
+108
View File
@@ -0,0 +1,108 @@
import * as FileSystem from 'expo-file-system';
import * as Print from 'expo-print';
import { LocalFile } from '@/api';
import { t } from '@/i18n';
// The native scanner (ML Kit on Android, VisionKit on iOS) returns cropped page
// images, which expo-print stitches into one PDF. Needs a development build.
// Loaded lazily so the bundle still runs where the native module is absent.
function loadScanner(): any | null {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('react-native-document-scanner-plugin');
return mod.default ?? mod;
} catch {
return null;
}
}
export function isScannerAvailable(): boolean {
return loadScanner() != null;
}
export interface ScanResult {
file: LocalFile;
pageCount: number;
}
/** Returns null if the user cancels without capturing a page. */
export async function scanDocumentToPdf(fileName = 'Scan'): Promise<ScanResult | null> {
const Scanner = loadScanner();
if (!Scanner) {
throw new Error(t('files.scannerNeedsDevBuild'));
}
const { scannedImages } = await Scanner.scanDocument({ croppedImageQuality: 100 });
if (!scannedImages || scannedImages.length === 0) {
return null; // cancelled
}
const pdfUri = await imagesToPdf(scannedImages, fileName);
const info = await FileSystem.getInfoAsync(pdfUri);
return {
file: {
uri: pdfUri,
name: `${sanitize(fileName)}.pdf`,
mimeType: 'application/pdf',
sizeBytes: info.exists ? (info.size ?? 0) : 0,
},
pageCount: scannedImages.length,
};
}
/** Multi-page A4 PDF from page images, stored persistently. */
export async function imagesToPdf(imageUris: string[], fileName: string): Promise<string> {
const pages = await Promise.all(
imageUris.map(async (uri) => {
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
const mime = uri.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
return `
<div class="page">
<img src="data:${mime};base64,${base64}" />
</div>`;
}),
);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.page {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
page-break-after: always;
background: #fff;
}
.page:last-child { page-break-after: auto; }
img { max-width: 100%; max-height: 100%; object-fit: contain; }
</style>
</head>
<body>${pages.join('')}</body>
</html>`;
const { uri: tmpUri } = await Print.printToFileAsync({ html, base64: false });
const dest = `${FileSystem.documentDirectory}${sanitize(fileName)}-${Date.now()}.pdf`;
try {
await FileSystem.moveAsync({ from: tmpUri, to: dest });
return dest;
} catch {
// Cross-volume move can fail; the temp file is still valid.
return tmpUri;
}
}
function sanitize(name: string): string {
return name.replace(/[^\w\-]+/g, '_').replace(/^_+|_+$/g, '') || 'Scan';
}
+77
View File
@@ -0,0 +1,77 @@
export * from './tokens';
import { colors } from './tokens';
// Backend categories are free-form pages, so unknown slugs are hashed into a
// stable colour instead of all sharing one accent.
export const CATEGORY_PALETTE = [
'#2c8c9e', // teal
'#5b6cc4', // indigo
'#c5862a', // amber
'#4a7a4d', // moss
'#8a5cb8', // violet
'#c45d8a', // rose
'#b56a3b', // clay
] as const;
/** Known categories keep their original colours; the rest are hashed. */
const NAMED_CATEGORY_COLORS: Record<string, string> = {
ownership: '#2c8c9e',
rental: '#5b6cc4',
sale: '#c5862a',
technical: '#4a7a4d',
certifications: '#8a5cb8',
certs: '#8a5cb8',
warranties: '#c45d8a',
warranty: '#c45d8a',
maintenance: '#b56a3b',
};
export function categoryColor(cat?: string | null): string {
if (!cat) return colors.slate500;
const key = cat.toLowerCase();
const named = NAMED_CATEGORY_COLORS[key];
if (named) return named;
// Stable, so a category keeps its colour across screens.
let hash = 0;
for (let i = 0; i < key.length; i += 1) {
hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
}
return CATEGORY_PALETTE[hash % CATEGORY_PALETTE.length];
}
/** Tinted background for a coloured icon chip. */
export function tint(hex: string, alpha = '1f'): string {
return `${hex}${alpha}`;
}
/** Two stops, dark bottom-right, so white type stays readable on top. */
export const COVER_GRADIENTS: Record<string, readonly [string, string]> = {
villa: ['#2c6e7c', '#16263d'],
house: ['#2c6e7c', '#16263d'],
apartment: ['#3a4f7a', '#1b2540'],
building: ['#3a4f7a', '#1b2540'],
commercial: ['#5b6cc4', '#1d2547'],
land: ['#4a7a4d', '#1c3120'],
};
export function coverGradient(type?: string | null): readonly [string, string] {
return COVER_GRADIENTS[type ?? ''] ?? [colors.ink700, colors.ink900];
}
/** Expiry status → badge style. */
export type ExpiryTone = 'valid' | 'expiring' | 'expired' | 'none';
export function expiryTone(status?: string | null): ExpiryTone {
switch ((status ?? '').toLowerCase()) {
case 'valid':
return 'valid';
case 'expiring':
case 'expiring_soon':
return 'expiring';
case 'expired':
return 'expired';
default:
return 'none';
}
}
+116
View File
@@ -0,0 +1,116 @@
/** Single source of truth for colours, spacing, radii and typography. */
export const colors = {
// Brand / accent — teal
teal50: '#e9f6f8',
teal100: '#cdeaef',
teal300: '#7fc9d4',
teal400: '#45a3b4',
teal500: '#2f8d9e',
teal600: '#237687',
teal700: '#1b5c6a',
// Ink (near-black brand darks)
ink700: '#243240',
ink800: '#1a2632',
ink900: '#121b24',
// Slate / neutral grays
slate400: '#94a3b8',
slate500: '#64748b',
slate600: '#475569',
slate700: '#334155',
// Surfaces
bgApp: '#eef2f7',
surface: '#ffffff',
surfaceSunken: '#f4f6fa',
border: '#e3e8ef',
borderStrong: '#cbd5e1',
// Text
ink: '#121b24',
textStrong: '#0f1720',
textMuted: '#64748b',
textMuted2: '#94a3b8',
onDark: '#ffffff',
onDarkMuted: 'rgba(255,255,255,0.72)',
// Status
successBg: '#e7f6ec',
successText: '#1f7a44',
warningBg: '#fbf1dc',
warningText: '#b7791f',
dangerBg: '#fbe7e9',
dangerText: '#c0344a',
danger600: '#c0344a',
infoBg: '#e6f0fb',
infoText: '#2b6cb0',
} as const;
export const spacing = {
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 24,
7: 32,
8: 40,
9: 56,
} as const;
export const radii = {
sm: 8,
md: 12,
lg: 16,
xl: 22,
pill: 999,
} as const;
export const font = {
display: 'System', // maps to Space Grotesk when custom fonts are loaded
body: 'System', // Inter
mono: 'System', // IBM Plex Mono
} as const;
export const fontSize = {
'2xs': 11,
xs: 12,
sm: 13,
base: 15,
md: 16,
lg: 18,
xl: 20,
'2xl': 24,
'3xl': 28,
'4xl': 34,
'5xl': 42,
} as const;
export const shadow = {
xs: {
shadowColor: '#0b1a2b',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
sm: {
shadowColor: '#0b1a2b',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.07,
shadowRadius: 10,
elevation: 3,
},
md: {
shadowColor: '#0b1a2b',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.1,
shadowRadius: 20,
elevation: 6,
},
} as const;
export type AppColors = typeof colors;
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}