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