feat(mobile): add pre-login legal pages, multi-image selection, file detail view, search, i18n, HEIC support
- Add Privacy Policy, Terms of Service, and Imprint links to WelcomeScreen and LoginScreen for GDPR/Apple compliance (pre-login access) - Enable multiple image selection in photo library picker - Add HEIC/HEIF image support to backend (allowed_types, convert_to_pdf, upload handler) - Create FileDetailScreen with processing status and logs - Add search bar to FilesScreen with debounced search - Set up i18n with expo-localization (EN, DE, ES, FR, IT) - Add language selector to ProfileScreen settings - Add Imprint link to ProfileScreen legal section - Update docs and tests Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -1477,6 +1477,8 @@ async def ui_upload(
|
||||
".tif",
|
||||
".webp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
}:
|
||||
# If it's an image, convert to PDF first
|
||||
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
|
||||
|
||||
@@ -205,7 +205,7 @@ def convert_to_pdf(
|
||||
".pdf", # PDF (already in PDF format but can be processed)
|
||||
}
|
||||
|
||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg"}
|
||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg", ".heic", ".heif"}
|
||||
|
||||
HTML_EXTENSIONS = {".html", ".htm"}
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ IMAGE_MIME_TYPES: set[str] = {
|
||||
"image/tiff",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -124,6 +126,8 @@ ALLOWED_EXTENSIONS: set[str] = {
|
||||
".tif",
|
||||
".webp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
# Web
|
||||
".html",
|
||||
".htm",
|
||||
@@ -234,7 +238,7 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
|
||||
},
|
||||
"images": {
|
||||
"label": "Images",
|
||||
"description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg)",
|
||||
"description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg, .heic, .heif)",
|
||||
"mime_types": frozenset(
|
||||
{
|
||||
"image/jpeg",
|
||||
@@ -245,6 +249,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
|
||||
"image/tiff",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
}
|
||||
),
|
||||
"extensions": frozenset(
|
||||
@@ -258,6 +264,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
|
||||
".tif",
|
||||
".webp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
}
|
||||
),
|
||||
},
|
||||
|
||||
+82
-5
@@ -12,9 +12,14 @@ DocuElevate includes a native mobile application for iOS and Android built with
|
||||
| Auto-generated API token | ✅ | ✅ |
|
||||
| Camera capture → upload | ✅ | ✅ |
|
||||
| File picker upload | ✅ | ✅ |
|
||||
| Multi-image selection from library | ✅ | ✅ |
|
||||
| Share Sheet / Share Intent | ✅ | ✅ |
|
||||
| Push notifications | ✅ | ✅ |
|
||||
| Document list | ✅ | ✅ |
|
||||
| Document list with search | ✅ | ✅ |
|
||||
| File detail view with processing logs | ✅ | ✅ |
|
||||
| Pre-login legal pages (GDPR) | ✅ | ✅ |
|
||||
| Localization (EN, DE, ES, FR, IT) | ✅ | ✅ |
|
||||
| Language selection | ✅ | ✅ |
|
||||
| Dark mode | ✅ | ✅ |
|
||||
|
||||
## Getting Started (Development)
|
||||
@@ -171,8 +176,8 @@ curl -X DELETE -H "Authorization: Bearer <token>" https://your-server/api/mobile
|
||||
|
||||
1. Open the **Upload** tab.
|
||||
2. Tap **Photos**.
|
||||
3. Select an existing photo from the device's photo library.
|
||||
4. The image is uploaded and queued for processing.
|
||||
3. Select one or more photos from the device's photo library (multi-selection is supported).
|
||||
4. All selected images are uploaded and queued for processing.
|
||||
|
||||
### File Picker
|
||||
|
||||
@@ -241,6 +246,66 @@ If a file upload fails (e.g. due to network issues or a server error), the faile
|
||||
|
||||
The retry re-uses the original file URI so no re-selection is needed.
|
||||
|
||||
## Document Search
|
||||
|
||||
The **Files** tab includes a search bar at the top that lets users search through their processed documents by filename. Searches are debounced (400ms) to avoid excessive API calls. Clear the search with the ✕ button to return to the full list.
|
||||
|
||||
## File Detail View
|
||||
|
||||
Tapping any document in the **Files** tab opens a detail view showing:
|
||||
|
||||
- **File metadata**: filename, file size, MIME type, upload date, and file hash
|
||||
- **Processing status**: current status with a colour-coded icon
|
||||
- **Processing log**: chronological list of processing steps with individual status indicators and timestamps
|
||||
|
||||
Pull-to-refresh updates the detail view. This replicates the web interface at `/files/{id}` and `/files/{id}/detail` in a mobile-friendly layout.
|
||||
|
||||
## Legal & Compliance
|
||||
|
||||
### GDPR & Apple App Store Compliance
|
||||
|
||||
Privacy Policy, Terms of Service, and Imprint links are accessible **before login** from both the **Welcome Screen** and the **Login Screen**. This ensures compliance with:
|
||||
|
||||
- **GDPR** (General Data Protection Regulation) – users must be able to review the privacy policy before providing personal data
|
||||
- **Apple App Store Review Guidelines** – apps must provide accessible privacy information before account creation
|
||||
|
||||
Post-login, the same links are available in the **Profile** tab under the "Legal" section.
|
||||
|
||||
## Localization (i18n)
|
||||
|
||||
The mobile app supports five languages with automatic device-locale detection:
|
||||
|
||||
| Language | Code | Status |
|
||||
|----------|------|--------|
|
||||
| English | `en` | ✅ Complete |
|
||||
| German (Deutsch) | `de` | ✅ Complete |
|
||||
| Spanish (Español) | `es` | ✅ Complete |
|
||||
| French (Français) | `fr` | ✅ Complete |
|
||||
| Italian (Italiano) | `it` | ✅ Complete |
|
||||
|
||||
### How it works
|
||||
|
||||
1. On app launch, `expo-localization` detects the device's preferred language
|
||||
2. If the device language matches a supported locale, that language is used automatically
|
||||
3. If no match is found, English is used as the fallback
|
||||
4. Users can manually switch languages from the **Profile** tab → **Settings** → **Language**
|
||||
|
||||
### Adding a new language
|
||||
|
||||
1. Create a new translation file in `mobile/src/i18n/` (e.g. `pt.json` for Portuguese)
|
||||
2. Copy the structure from `en.json` and translate all values
|
||||
3. Import the new file in `mobile/src/i18n/index.ts`
|
||||
4. Add it to the `translations` object and `getSupportedLanguages()` array
|
||||
|
||||
## User Settings
|
||||
|
||||
The **Profile** tab includes a **Settings** section where users can:
|
||||
|
||||
- **Change language**: Select from the supported languages (English, German, Spanish, French, Italian)
|
||||
- View server connection details
|
||||
- Access legal documents (Privacy Policy, Terms of Service, Imprint)
|
||||
- Sign out or delete their account
|
||||
|
||||
## Mobile API Endpoints
|
||||
|
||||
The backend exposes a dedicated `/api/mobile/` namespace:
|
||||
@@ -346,8 +411,20 @@ mobile/
|
||||
│ ├── LoginScreen.tsx # Server URL + SSO button + QR code scanner
|
||||
│ ├── QRScannerScreen.tsx # Camera-based QR code scanner for login
|
||||
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
|
||||
│ ├── FilesScreen.tsx # Processed document list
|
||||
│ └── ProfileScreen.tsx # User profile + sign out
|
||||
│ ├── FilesScreen.tsx # Processed document list with search
|
||||
│ ├── FileDetailScreen.tsx # File detail view with processing logs
|
||||
│ ├── ProfileScreen.tsx # User profile + settings + sign out
|
||||
│ └── WelcomeScreen.tsx # Pre-login welcome with legal links
|
||||
├── i18n/ # Localization (i18n)
|
||||
│ ├── index.ts # i18n module (locale detection, t() function)
|
||||
│ ├── en.json # English translations
|
||||
│ ├── de.json # German translations
|
||||
│ ├── es.json # Spanish translations
|
||||
│ ├── fr.json # French translations
|
||||
│ └── it.json # Italian translations
|
||||
├── utils/
|
||||
│ ├── mimeTypes.ts # MIME type mapping for file extensions
|
||||
│ └── normalizeUri.ts # URI normalization for deduplication
|
||||
└── services/
|
||||
└── api.ts # DocuElevate REST API client
|
||||
```
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ DocuElevate provides multiple convenient ways to upload documents to the system.
|
||||
|
||||
#### Supported File Types
|
||||
- **Documents**: PDF, Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx)
|
||||
- **Images**: JPEG, PNG, GIF, BMP, TIFF, WebP, SVG
|
||||
- **Images**: JPEG, PNG, GIF, BMP, TIFF, WebP, SVG, HEIC, HEIF
|
||||
- **Text**: Plain text (.txt), CSV, RTF, HTML, XML, Markdown
|
||||
- **Maximum file size**: 500MB per file
|
||||
|
||||
|
||||
@@ -67,6 +67,15 @@ export default function TabLayout() {
|
||||
headerTitle: "Profile",
|
||||
}}
|
||||
/>
|
||||
{/* File detail screen – hidden from tab bar, accessed via navigation */}
|
||||
<Tabs.Screen
|
||||
name="file-detail"
|
||||
options={{
|
||||
href: null,
|
||||
title: "File Details",
|
||||
headerTitle: "File Details",
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* File detail route – displays processing status and logs for a single file.
|
||||
*/
|
||||
export { default } from "../../src/screens/FileDetailScreen";
|
||||
@@ -36,6 +36,7 @@
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-localization": "~16.0.6",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"common": {
|
||||
"retry": "Erneut versuchen",
|
||||
"cancel": "Abbrechen",
|
||||
"back": "Zurück",
|
||||
"error": "Fehler",
|
||||
"loading": "Laden…",
|
||||
"search": "Suchen",
|
||||
"clear_search": "Suche löschen"
|
||||
},
|
||||
"welcome": {
|
||||
"tagline": "Intelligente Dokumentenverarbeitung",
|
||||
"description": "Dokumente einlesen, OCR durchführen, Metadaten mit KI extrahieren und Dateien in Ihren Cloud-Speicher leiten – alles in einer nahtlosen Pipeline.",
|
||||
"get_started": "Loslegen",
|
||||
"hint": "Verbinden Sie sich mit Ihrem selbst gehosteten oder Cloud-DocuElevate-Server.",
|
||||
"feature_ocr_title": "OCR & Texterkennung",
|
||||
"feature_ocr_desc": "Gescannte PDFs und Bilder automatisch in durchsuchbaren Text umwandeln.",
|
||||
"feature_ai_title": "KI-Metadatenextraktion",
|
||||
"feature_ai_desc": "KI klassifiziert Dokumente und extrahiert Schlüsselfelder wie Datum, Beträge und Betreff.",
|
||||
"feature_cloud_title": "Multi-Cloud-Speicher",
|
||||
"feature_cloud_desc": "Verarbeitete Dateien an Dropbox, Google Drive, OneDrive, S3, Nextcloud und mehr weiterleiten."
|
||||
},
|
||||
"login": {
|
||||
"server_url": "Server-URL",
|
||||
"server_url_placeholder": "https://ihr-docuelevate-server.com",
|
||||
"sign_in_sso": "Mit SSO anmelden",
|
||||
"scan_qr": "📱 QR-Code scannen zum Anmelden",
|
||||
"hint": "Melden Sie sich per SSO an oder scannen Sie einen QR-Code aus der Web-App.",
|
||||
"back": "← Zurück",
|
||||
"or": "oder",
|
||||
"server_url_required": "Server-URL erforderlich",
|
||||
"server_url_required_msg": "Bitte geben Sie die URL Ihres DocuElevate-Servers ein.",
|
||||
"invalid_url": "Ungültige URL",
|
||||
"invalid_url_msg": "Die Server-URL muss mit http:// oder https:// beginnen",
|
||||
"sign_in_failed": "Anmeldung fehlgeschlagen",
|
||||
"qr_login_failed": "QR-Anmeldung fehlgeschlagen"
|
||||
},
|
||||
"upload": {
|
||||
"camera": "Kamera",
|
||||
"photos": "Fotos",
|
||||
"files": "Dateien",
|
||||
"camera_access_title": "Kamerazugriff erforderlich",
|
||||
"camera_access_msg": "Bitte erlauben Sie den Kamerazugriff in den Einstellungen, um Dokumente aufzunehmen.",
|
||||
"photo_access_title": "Fotobibliothek-Zugriff erforderlich",
|
||||
"photo_access_msg": "Bitte erlauben Sie den Zugriff auf die Fotobibliothek in den Einstellungen.",
|
||||
"file_picker_error": "Dateiauswahl-Fehler",
|
||||
"file_picker_error_msg": "Dateiauswahl konnte nicht geöffnet werden",
|
||||
"empty_title": "Tippen Sie auf Kamera, Fotos oder Dateien, um ein Dokument hochzuladen.",
|
||||
"empty_hint": "Sie können auch Dateien aus anderen Apps direkt an DocuElevate senden.",
|
||||
"sign_in_required": "Bitte melden Sie sich an, um Dokumente hochzuladen.",
|
||||
"status_queued": "In der Warteschlange…",
|
||||
"status_processing": "Wird verarbeitet…",
|
||||
"status_completed": "Verarbeitet",
|
||||
"status_failed": "Verarbeitung fehlgeschlagen",
|
||||
"status_duplicate": "Duplikat – bereits verarbeitet",
|
||||
"tap_retry": "Zum Wiederholen tippen",
|
||||
"retry_title": "Upload wiederholen",
|
||||
"retry_msg": "Möchten Sie den Upload von \"{filename}\" wiederholen?",
|
||||
"capture_label": "Dokument mit Kamera aufnehmen",
|
||||
"photo_label": "Foto aus der Bibliothek auswählen",
|
||||
"file_label": "Datei vom Gerät auswählen"
|
||||
},
|
||||
"files": {
|
||||
"title": "Meine Dokumente",
|
||||
"search_placeholder": "Dokumente durchsuchen…",
|
||||
"empty_title": "Noch keine Dokumente.",
|
||||
"empty_hint": "Laden Sie ein Dokument über den Upload-Tab hoch.",
|
||||
"search_empty": "Keine Dokumente gefunden.",
|
||||
"search_empty_hint": "Versuchen Sie einen anderen Suchbegriff.",
|
||||
"view_details": "Details für {filename} anzeigen"
|
||||
},
|
||||
"file_detail": {
|
||||
"title": "Dateidetails",
|
||||
"back": "Zurück zu Dateien",
|
||||
"file_size": "Dateigröße",
|
||||
"mime_type": "MIME-Typ",
|
||||
"uploaded": "Hochgeladen",
|
||||
"file_hash": "Datei-Hash",
|
||||
"last_step": "Letzter Schritt",
|
||||
"total_steps": "Gesamtschritte",
|
||||
"processing_log": "Verarbeitungsprotokoll",
|
||||
"no_logs": "Noch keine Verarbeitungsprotokolle.",
|
||||
"file_not_found": "Datei nicht gefunden"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"not_signed_in": "Nicht angemeldet",
|
||||
"connection": "Verbindung",
|
||||
"server": "Server",
|
||||
"user_id": "Benutzer-ID",
|
||||
"legal": "Rechtliches",
|
||||
"privacy_policy": "Datenschutzrichtlinie",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"imprint": "Impressum",
|
||||
"sign_out": "Abmelden",
|
||||
"sign_out_title": "Abmelden",
|
||||
"sign_out_msg": "Möchten Sie sich wirklich abmelden?",
|
||||
"delete_account": "Konto löschen",
|
||||
"delete_account_title": "Konto löschen",
|
||||
"delete_account_msg": "Dadurch werden Ihr Konto und alle zugehörigen Daten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"could_not_open": "Konnte {page} nicht öffnen. Bitte versuchen Sie es erneut.",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Datenschutz",
|
||||
"terms": "AGB",
|
||||
"imprint": "Impressum"
|
||||
},
|
||||
"tabs": {
|
||||
"upload": "Hochladen",
|
||||
"files": "Dateien",
|
||||
"profile": "Profil"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"common": {
|
||||
"retry": "Retry",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"error": "Error",
|
||||
"loading": "Loading…",
|
||||
"search": "Search",
|
||||
"clear_search": "Clear search"
|
||||
},
|
||||
"welcome": {
|
||||
"tagline": "Intelligent Document Processing",
|
||||
"description": "Ingest documents, run OCR, extract metadata with AI, and route files to your cloud storage — all in one seamless pipeline.",
|
||||
"get_started": "Get Started",
|
||||
"hint": "Connect to your self-hosted or cloud DocuElevate server.",
|
||||
"feature_ocr_title": "OCR & Text Extraction",
|
||||
"feature_ocr_desc": "Convert scanned PDFs and images into fully searchable text automatically.",
|
||||
"feature_ai_title": "AI Metadata Extraction",
|
||||
"feature_ai_desc": "AI classifies documents and pulls out key fields like dates, amounts, and subjects.",
|
||||
"feature_cloud_title": "Multi-Cloud Storage",
|
||||
"feature_cloud_desc": "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more."
|
||||
},
|
||||
"login": {
|
||||
"server_url": "Server URL",
|
||||
"server_url_placeholder": "https://your-docuelevate-server.com",
|
||||
"sign_in_sso": "Sign in with SSO",
|
||||
"scan_qr": "📱 Scan QR Code to Login",
|
||||
"hint": "Sign in via SSO or scan a QR code from the web app.",
|
||||
"back": "← Back",
|
||||
"or": "or",
|
||||
"server_url_required": "Server URL required",
|
||||
"server_url_required_msg": "Please enter the URL of your DocuElevate server.",
|
||||
"invalid_url": "Invalid URL",
|
||||
"invalid_url_msg": "The server URL must start with http:// or https://",
|
||||
"sign_in_failed": "Sign-in failed",
|
||||
"qr_login_failed": "QR Login Failed"
|
||||
},
|
||||
"upload": {
|
||||
"camera": "Camera",
|
||||
"photos": "Photos",
|
||||
"files": "Files",
|
||||
"camera_access_title": "Camera access required",
|
||||
"camera_access_msg": "Please grant camera access in Settings to capture documents.",
|
||||
"photo_access_title": "Photo library access required",
|
||||
"photo_access_msg": "Please grant photo library access in Settings to select images.",
|
||||
"file_picker_error": "File picker error",
|
||||
"file_picker_error_msg": "Could not open file picker",
|
||||
"empty_title": "Tap Camera, Photos, or Files to upload a document.",
|
||||
"empty_hint": "You can also share files from other apps directly to DocuElevate.",
|
||||
"sign_in_required": "Please sign in to upload documents.",
|
||||
"status_queued": "Queued for processing…",
|
||||
"status_processing": "Processing…",
|
||||
"status_completed": "Processed",
|
||||
"status_failed": "Processing failed",
|
||||
"status_duplicate": "Duplicate – already processed",
|
||||
"tap_retry": "Tap to retry",
|
||||
"retry_title": "Retry Upload",
|
||||
"retry_msg": "Do you want to retry uploading \"{filename}\"?",
|
||||
"capture_label": "Capture document with camera",
|
||||
"photo_label": "Select photo from library",
|
||||
"file_label": "Pick file from device"
|
||||
},
|
||||
"files": {
|
||||
"title": "My Documents",
|
||||
"search_placeholder": "Search documents…",
|
||||
"empty_title": "No documents yet.",
|
||||
"empty_hint": "Upload a document from the Upload tab to get started.",
|
||||
"search_empty": "No documents match your search.",
|
||||
"search_empty_hint": "Try a different search term.",
|
||||
"view_details": "View details for {filename}"
|
||||
},
|
||||
"file_detail": {
|
||||
"title": "File Details",
|
||||
"back": "Back to Files",
|
||||
"file_size": "File Size",
|
||||
"mime_type": "MIME Type",
|
||||
"uploaded": "Uploaded",
|
||||
"file_hash": "File Hash",
|
||||
"last_step": "Last Step",
|
||||
"total_steps": "Total Steps",
|
||||
"processing_log": "Processing Log",
|
||||
"no_logs": "No processing logs yet.",
|
||||
"file_not_found": "File not found"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"not_signed_in": "Not signed in",
|
||||
"connection": "Connection",
|
||||
"server": "Server",
|
||||
"user_id": "User ID",
|
||||
"legal": "Legal",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"imprint": "Imprint",
|
||||
"sign_out": "Sign out",
|
||||
"sign_out_title": "Sign out",
|
||||
"sign_out_msg": "Are you sure you want to sign out?",
|
||||
"delete_account": "Delete Account",
|
||||
"delete_account_title": "Delete Account",
|
||||
"delete_account_msg": "This will permanently delete your account and all associated data. This action cannot be undone.",
|
||||
"could_not_open": "Could not open the {page}. Please try again.",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"terms": "Terms",
|
||||
"imprint": "Imprint"
|
||||
},
|
||||
"tabs": {
|
||||
"upload": "Upload",
|
||||
"files": "Files",
|
||||
"profile": "Profile"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"common": {
|
||||
"retry": "Reintentar",
|
||||
"cancel": "Cancelar",
|
||||
"back": "Atrás",
|
||||
"error": "Error",
|
||||
"loading": "Cargando…",
|
||||
"search": "Buscar",
|
||||
"clear_search": "Borrar búsqueda"
|
||||
},
|
||||
"welcome": {
|
||||
"tagline": "Procesamiento Inteligente de Documentos",
|
||||
"description": "Ingiere documentos, ejecuta OCR, extrae metadatos con IA y envía archivos a tu almacenamiento en la nube — todo en una sola línea de trabajo.",
|
||||
"get_started": "Comenzar",
|
||||
"hint": "Conéctate a tu servidor DocuElevate autoalojado o en la nube.",
|
||||
"feature_ocr_title": "OCR y Extracción de Texto",
|
||||
"feature_ocr_desc": "Convierte PDFs e imágenes escaneadas en texto completamente buscable automáticamente.",
|
||||
"feature_ai_title": "Extracción de Metadatos con IA",
|
||||
"feature_ai_desc": "La IA clasifica documentos y extrae campos clave como fechas, montos y asuntos.",
|
||||
"feature_cloud_title": "Almacenamiento Multi-Nube",
|
||||
"feature_cloud_desc": "Envía archivos procesados a Dropbox, Google Drive, OneDrive, S3, Nextcloud y más."
|
||||
},
|
||||
"login": {
|
||||
"server_url": "URL del Servidor",
|
||||
"server_url_placeholder": "https://tu-servidor-docuelevate.com",
|
||||
"sign_in_sso": "Iniciar sesión con SSO",
|
||||
"scan_qr": "📱 Escanear código QR para iniciar sesión",
|
||||
"hint": "Inicia sesión mediante SSO o escanea un código QR desde la app web.",
|
||||
"back": "← Atrás",
|
||||
"or": "o",
|
||||
"server_url_required": "URL del servidor requerida",
|
||||
"server_url_required_msg": "Por favor ingresa la URL de tu servidor DocuElevate.",
|
||||
"invalid_url": "URL inválida",
|
||||
"invalid_url_msg": "La URL del servidor debe comenzar con http:// o https://",
|
||||
"sign_in_failed": "Error al iniciar sesión",
|
||||
"qr_login_failed": "Error en inicio de sesión QR"
|
||||
},
|
||||
"upload": {
|
||||
"camera": "Cámara",
|
||||
"photos": "Fotos",
|
||||
"files": "Archivos",
|
||||
"camera_access_title": "Acceso a la cámara requerido",
|
||||
"camera_access_msg": "Permite el acceso a la cámara en Ajustes para capturar documentos.",
|
||||
"photo_access_title": "Acceso a la biblioteca de fotos requerido",
|
||||
"photo_access_msg": "Permite el acceso a la biblioteca de fotos en Ajustes para seleccionar imágenes.",
|
||||
"file_picker_error": "Error del selector de archivos",
|
||||
"file_picker_error_msg": "No se pudo abrir el selector de archivos",
|
||||
"empty_title": "Toca Cámara, Fotos o Archivos para subir un documento.",
|
||||
"empty_hint": "También puedes compartir archivos desde otras apps directamente a DocuElevate.",
|
||||
"sign_in_required": "Inicia sesión para subir documentos.",
|
||||
"status_queued": "En cola para procesamiento…",
|
||||
"status_processing": "Procesando…",
|
||||
"status_completed": "Procesado",
|
||||
"status_failed": "Procesamiento fallido",
|
||||
"status_duplicate": "Duplicado – ya procesado",
|
||||
"tap_retry": "Toca para reintentar",
|
||||
"retry_title": "Reintentar Subida",
|
||||
"retry_msg": "¿Deseas reintentar la subida de \"{filename}\"?",
|
||||
"capture_label": "Capturar documento con la cámara",
|
||||
"photo_label": "Seleccionar foto de la biblioteca",
|
||||
"file_label": "Seleccionar archivo del dispositivo"
|
||||
},
|
||||
"files": {
|
||||
"title": "Mis Documentos",
|
||||
"search_placeholder": "Buscar documentos…",
|
||||
"empty_title": "Aún no hay documentos.",
|
||||
"empty_hint": "Sube un documento desde la pestaña Subir para comenzar.",
|
||||
"search_empty": "Ningún documento coincide con tu búsqueda.",
|
||||
"search_empty_hint": "Intenta con otro término de búsqueda.",
|
||||
"view_details": "Ver detalles de {filename}"
|
||||
},
|
||||
"file_detail": {
|
||||
"title": "Detalles del Archivo",
|
||||
"back": "Volver a Archivos",
|
||||
"file_size": "Tamaño",
|
||||
"mime_type": "Tipo MIME",
|
||||
"uploaded": "Subido",
|
||||
"file_hash": "Hash del Archivo",
|
||||
"last_step": "Último Paso",
|
||||
"total_steps": "Pasos Totales",
|
||||
"processing_log": "Registro de Procesamiento",
|
||||
"no_logs": "Aún no hay registros de procesamiento.",
|
||||
"file_not_found": "Archivo no encontrado"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Perfil",
|
||||
"not_signed_in": "No has iniciado sesión",
|
||||
"connection": "Conexión",
|
||||
"server": "Servidor",
|
||||
"user_id": "ID de Usuario",
|
||||
"legal": "Legal",
|
||||
"privacy_policy": "Política de Privacidad",
|
||||
"terms_of_service": "Términos de Servicio",
|
||||
"imprint": "Aviso Legal",
|
||||
"sign_out": "Cerrar sesión",
|
||||
"sign_out_title": "Cerrar sesión",
|
||||
"sign_out_msg": "¿Estás seguro de que deseas cerrar sesión?",
|
||||
"delete_account": "Eliminar Cuenta",
|
||||
"delete_account_title": "Eliminar Cuenta",
|
||||
"delete_account_msg": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.",
|
||||
"could_not_open": "No se pudo abrir {page}. Inténtalo de nuevo.",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Privacidad",
|
||||
"terms": "Términos",
|
||||
"imprint": "Aviso Legal"
|
||||
},
|
||||
"tabs": {
|
||||
"upload": "Subir",
|
||||
"files": "Archivos",
|
||||
"profile": "Perfil"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"common": {
|
||||
"retry": "Réessayer",
|
||||
"cancel": "Annuler",
|
||||
"back": "Retour",
|
||||
"error": "Erreur",
|
||||
"loading": "Chargement…",
|
||||
"search": "Rechercher",
|
||||
"clear_search": "Effacer la recherche"
|
||||
},
|
||||
"welcome": {
|
||||
"tagline": "Traitement Intelligent de Documents",
|
||||
"description": "Ingérez des documents, lancez l'OCR, extrayez les métadonnées avec l'IA et transférez les fichiers vers votre stockage cloud — le tout dans un flux unique.",
|
||||
"get_started": "Commencer",
|
||||
"hint": "Connectez-vous à votre serveur DocuElevate auto-hébergé ou cloud.",
|
||||
"feature_ocr_title": "OCR et Extraction de Texte",
|
||||
"feature_ocr_desc": "Convertissez automatiquement les PDF scannés et les images en texte entièrement consultable.",
|
||||
"feature_ai_title": "Extraction de Métadonnées par IA",
|
||||
"feature_ai_desc": "L'IA classe les documents et extrait les champs clés comme les dates, montants et sujets.",
|
||||
"feature_cloud_title": "Stockage Multi-Cloud",
|
||||
"feature_cloud_desc": "Transférez les fichiers traités vers Dropbox, Google Drive, OneDrive, S3, Nextcloud et plus."
|
||||
},
|
||||
"login": {
|
||||
"server_url": "URL du Serveur",
|
||||
"server_url_placeholder": "https://votre-serveur-docuelevate.com",
|
||||
"sign_in_sso": "Se connecter avec SSO",
|
||||
"scan_qr": "📱 Scanner le code QR pour se connecter",
|
||||
"hint": "Connectez-vous via SSO ou scannez un code QR depuis l'application web.",
|
||||
"back": "← Retour",
|
||||
"or": "ou",
|
||||
"server_url_required": "URL du serveur requise",
|
||||
"server_url_required_msg": "Veuillez entrer l'URL de votre serveur DocuElevate.",
|
||||
"invalid_url": "URL invalide",
|
||||
"invalid_url_msg": "L'URL du serveur doit commencer par http:// ou https://",
|
||||
"sign_in_failed": "Échec de la connexion",
|
||||
"qr_login_failed": "Échec de la connexion QR"
|
||||
},
|
||||
"upload": {
|
||||
"camera": "Appareil photo",
|
||||
"photos": "Photos",
|
||||
"files": "Fichiers",
|
||||
"camera_access_title": "Accès à l'appareil photo requis",
|
||||
"camera_access_msg": "Veuillez autoriser l'accès à l'appareil photo dans les Réglages pour capturer des documents.",
|
||||
"photo_access_title": "Accès à la photothèque requis",
|
||||
"photo_access_msg": "Veuillez autoriser l'accès à la photothèque dans les Réglages pour sélectionner des images.",
|
||||
"file_picker_error": "Erreur du sélecteur de fichiers",
|
||||
"file_picker_error_msg": "Impossible d'ouvrir le sélecteur de fichiers",
|
||||
"empty_title": "Appuyez sur Appareil photo, Photos ou Fichiers pour télécharger un document.",
|
||||
"empty_hint": "Vous pouvez aussi partager des fichiers depuis d'autres applications vers DocuElevate.",
|
||||
"sign_in_required": "Veuillez vous connecter pour télécharger des documents.",
|
||||
"status_queued": "En file d'attente…",
|
||||
"status_processing": "En cours de traitement…",
|
||||
"status_completed": "Traité",
|
||||
"status_failed": "Échec du traitement",
|
||||
"status_duplicate": "Doublon – déjà traité",
|
||||
"tap_retry": "Appuyez pour réessayer",
|
||||
"retry_title": "Réessayer le téléchargement",
|
||||
"retry_msg": "Voulez-vous réessayer le téléchargement de \"{filename}\" ?",
|
||||
"capture_label": "Capturer un document avec l'appareil photo",
|
||||
"photo_label": "Sélectionner une photo de la bibliothèque",
|
||||
"file_label": "Choisir un fichier depuis l'appareil"
|
||||
},
|
||||
"files": {
|
||||
"title": "Mes Documents",
|
||||
"search_placeholder": "Rechercher des documents…",
|
||||
"empty_title": "Pas encore de documents.",
|
||||
"empty_hint": "Téléchargez un document depuis l'onglet Télécharger pour commencer.",
|
||||
"search_empty": "Aucun document ne correspond à votre recherche.",
|
||||
"search_empty_hint": "Essayez un autre terme de recherche.",
|
||||
"view_details": "Voir les détails de {filename}"
|
||||
},
|
||||
"file_detail": {
|
||||
"title": "Détails du Fichier",
|
||||
"back": "Retour aux Fichiers",
|
||||
"file_size": "Taille",
|
||||
"mime_type": "Type MIME",
|
||||
"uploaded": "Téléchargé",
|
||||
"file_hash": "Hash du Fichier",
|
||||
"last_step": "Dernière Étape",
|
||||
"total_steps": "Étapes Totales",
|
||||
"processing_log": "Journal de Traitement",
|
||||
"no_logs": "Pas encore de journaux de traitement.",
|
||||
"file_not_found": "Fichier non trouvé"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"not_signed_in": "Non connecté",
|
||||
"connection": "Connexion",
|
||||
"server": "Serveur",
|
||||
"user_id": "ID Utilisateur",
|
||||
"legal": "Mentions Légales",
|
||||
"privacy_policy": "Politique de Confidentialité",
|
||||
"terms_of_service": "Conditions d'Utilisation",
|
||||
"imprint": "Mentions Légales",
|
||||
"sign_out": "Se déconnecter",
|
||||
"sign_out_title": "Se déconnecter",
|
||||
"sign_out_msg": "Êtes-vous sûr de vouloir vous déconnecter ?",
|
||||
"delete_account": "Supprimer le Compte",
|
||||
"delete_account_title": "Supprimer le Compte",
|
||||
"delete_account_msg": "Cela supprimera définitivement votre compte et toutes les données associées. Cette action est irréversible.",
|
||||
"could_not_open": "Impossible d'ouvrir {page}. Veuillez réessayer.",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Confidentialité",
|
||||
"terms": "Conditions",
|
||||
"imprint": "Mentions Légales"
|
||||
},
|
||||
"tabs": {
|
||||
"upload": "Télécharger",
|
||||
"files": "Fichiers",
|
||||
"profile": "Profil"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Lightweight i18n module for the DocuElevate mobile app.
|
||||
*
|
||||
* Uses the device locale (via expo-localization) to select the best matching
|
||||
* translation file. Falls back to English for missing keys or unsupported
|
||||
* locales.
|
||||
*
|
||||
* Supported languages: English, German, Spanish, French, Italian.
|
||||
*/
|
||||
|
||||
import { getLocales } from "expo-localization";
|
||||
|
||||
import de from "./de.json";
|
||||
import en from "./en.json";
|
||||
import es from "./es.json";
|
||||
import fr from "./fr.json";
|
||||
import it from "./it.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Translation catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TranslationMap = Record<string, Record<string, string>>;
|
||||
|
||||
const translations: Record<string, TranslationMap> = { en, de, es, fr, it };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locale detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve the best-matching language code from the device locale list. */
|
||||
function detectLanguage(): string {
|
||||
try {
|
||||
const locales = getLocales();
|
||||
if (locales.length > 0) {
|
||||
// Try exact match first (e.g. "de"), then fall back to language prefix
|
||||
const code = locales[0].languageCode?.toLowerCase();
|
||||
if (code && translations[code]) return code;
|
||||
}
|
||||
} catch {
|
||||
// getLocales() can throw on some platforms – default to English
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
let currentLanguage: string = detectLanguage();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Translate a dot-separated key, e.g. `t("upload.camera")`.
|
||||
*
|
||||
* Supports simple placeholder interpolation:
|
||||
* `t("upload.retry_msg", { filename: "doc.pdf" })`
|
||||
* replaces `{filename}` in the translated string.
|
||||
*
|
||||
* Falls back to the English value, then to the raw key if no translation
|
||||
* exists.
|
||||
*/
|
||||
export function t(key: string, params?: Record<string, string>): string {
|
||||
const [section, ...rest] = key.split(".");
|
||||
const subKey = rest.join(".");
|
||||
|
||||
let value =
|
||||
translations[currentLanguage]?.[section]?.[subKey] ??
|
||||
translations.en?.[section]?.[subKey] ??
|
||||
key;
|
||||
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
value = value.replace(new RegExp(`\\{${k}\\}`, "g"), v);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Return the current language code (e.g. "en", "de"). */
|
||||
export function getLanguage(): string {
|
||||
return currentLanguage;
|
||||
}
|
||||
|
||||
/** Override the language manually (e.g. from user settings). */
|
||||
export function setLanguage(lang: string): void {
|
||||
if (translations[lang]) {
|
||||
currentLanguage = lang;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the list of supported language codes. */
|
||||
export function getSupportedLanguages(): { code: string; label: string }[] {
|
||||
return [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "es", label: "Español" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "it", label: "Italiano" },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"common": {
|
||||
"retry": "Riprova",
|
||||
"cancel": "Annulla",
|
||||
"back": "Indietro",
|
||||
"error": "Errore",
|
||||
"loading": "Caricamento…",
|
||||
"search": "Cerca",
|
||||
"clear_search": "Cancella ricerca"
|
||||
},
|
||||
"welcome": {
|
||||
"tagline": "Elaborazione Intelligente dei Documenti",
|
||||
"description": "Acquisisci documenti, esegui l'OCR, estrai metadati con l'IA e invia i file al tuo cloud storage — tutto in un unico flusso.",
|
||||
"get_started": "Inizia",
|
||||
"hint": "Collegati al tuo server DocuElevate self-hosted o cloud.",
|
||||
"feature_ocr_title": "OCR ed Estrazione Testo",
|
||||
"feature_ocr_desc": "Converti automaticamente PDF e immagini scansionate in testo completamente ricercabile.",
|
||||
"feature_ai_title": "Estrazione Metadati con IA",
|
||||
"feature_ai_desc": "L'IA classifica i documenti ed estrae campi chiave come date, importi e oggetti.",
|
||||
"feature_cloud_title": "Archiviazione Multi-Cloud",
|
||||
"feature_cloud_desc": "Invia i file elaborati a Dropbox, Google Drive, OneDrive, S3, Nextcloud e altro."
|
||||
},
|
||||
"login": {
|
||||
"server_url": "URL del Server",
|
||||
"server_url_placeholder": "https://il-tuo-server-docuelevate.com",
|
||||
"sign_in_sso": "Accedi con SSO",
|
||||
"scan_qr": "📱 Scansiona il codice QR per accedere",
|
||||
"hint": "Accedi tramite SSO o scansiona un codice QR dall'app web.",
|
||||
"back": "← Indietro",
|
||||
"or": "o",
|
||||
"server_url_required": "URL del server richiesto",
|
||||
"server_url_required_msg": "Inserisci l'URL del tuo server DocuElevate.",
|
||||
"invalid_url": "URL non valido",
|
||||
"invalid_url_msg": "L'URL del server deve iniziare con http:// o https://",
|
||||
"sign_in_failed": "Accesso fallito",
|
||||
"qr_login_failed": "Accesso QR fallito"
|
||||
},
|
||||
"upload": {
|
||||
"camera": "Fotocamera",
|
||||
"photos": "Foto",
|
||||
"files": "File",
|
||||
"camera_access_title": "Accesso alla fotocamera richiesto",
|
||||
"camera_access_msg": "Consenti l'accesso alla fotocamera nelle Impostazioni per acquisire documenti.",
|
||||
"photo_access_title": "Accesso alla libreria foto richiesto",
|
||||
"photo_access_msg": "Consenti l'accesso alla libreria foto nelle Impostazioni per selezionare immagini.",
|
||||
"file_picker_error": "Errore nel selettore file",
|
||||
"file_picker_error_msg": "Impossibile aprire il selettore file",
|
||||
"empty_title": "Tocca Fotocamera, Foto o File per caricare un documento.",
|
||||
"empty_hint": "Puoi anche condividere file da altre app direttamente su DocuElevate.",
|
||||
"sign_in_required": "Accedi per caricare documenti.",
|
||||
"status_queued": "In coda per l'elaborazione…",
|
||||
"status_processing": "Elaborazione in corso…",
|
||||
"status_completed": "Elaborato",
|
||||
"status_failed": "Elaborazione fallita",
|
||||
"status_duplicate": "Duplicato – già elaborato",
|
||||
"tap_retry": "Tocca per riprovare",
|
||||
"retry_title": "Riprova Caricamento",
|
||||
"retry_msg": "Vuoi riprovare a caricare \"{filename}\"?",
|
||||
"capture_label": "Acquisisci documento con la fotocamera",
|
||||
"photo_label": "Seleziona foto dalla libreria",
|
||||
"file_label": "Seleziona file dal dispositivo"
|
||||
},
|
||||
"files": {
|
||||
"title": "I Miei Documenti",
|
||||
"search_placeholder": "Cerca documenti…",
|
||||
"empty_title": "Nessun documento ancora.",
|
||||
"empty_hint": "Carica un documento dalla scheda Carica per iniziare.",
|
||||
"search_empty": "Nessun documento corrisponde alla tua ricerca.",
|
||||
"search_empty_hint": "Prova con un altro termine di ricerca.",
|
||||
"view_details": "Visualizza dettagli per {filename}"
|
||||
},
|
||||
"file_detail": {
|
||||
"title": "Dettagli File",
|
||||
"back": "Torna ai File",
|
||||
"file_size": "Dimensione",
|
||||
"mime_type": "Tipo MIME",
|
||||
"uploaded": "Caricato",
|
||||
"file_hash": "Hash del File",
|
||||
"last_step": "Ultimo Passaggio",
|
||||
"total_steps": "Passaggi Totali",
|
||||
"processing_log": "Registro di Elaborazione",
|
||||
"no_logs": "Nessun registro di elaborazione ancora.",
|
||||
"file_not_found": "File non trovato"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profilo",
|
||||
"not_signed_in": "Non connesso",
|
||||
"connection": "Connessione",
|
||||
"server": "Server",
|
||||
"user_id": "ID Utente",
|
||||
"legal": "Legale",
|
||||
"privacy_policy": "Informativa sulla Privacy",
|
||||
"terms_of_service": "Termini di Servizio",
|
||||
"imprint": "Note Legali",
|
||||
"sign_out": "Esci",
|
||||
"sign_out_title": "Esci",
|
||||
"sign_out_msg": "Sei sicuro di voler uscire?",
|
||||
"delete_account": "Elimina Account",
|
||||
"delete_account_title": "Elimina Account",
|
||||
"delete_account_msg": "Questo eliminerà permanentemente il tuo account e tutti i dati associati. Questa azione non può essere annullata.",
|
||||
"could_not_open": "Impossibile aprire {page}. Riprova.",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Privacy",
|
||||
"terms": "Termini",
|
||||
"imprint": "Note Legali"
|
||||
},
|
||||
"tabs": {
|
||||
"upload": "Carica",
|
||||
"files": "File",
|
||||
"profile": "Profilo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* FileDetailScreen – shows detailed status and processing logs for a single file.
|
||||
*
|
||||
* Replicates the web /files/:id and /files/:id/detail views in a
|
||||
* mobile-friendly layout. Displays file metadata, processing status with
|
||||
* a progress indicator, and a chronological list of processing log entries.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import type { FileDetail } from "../services/api";
|
||||
import api from "../services/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) return "–";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
completed: "#059669",
|
||||
processing: "#d97706",
|
||||
pending: "#6b7280",
|
||||
failed: "#dc2626",
|
||||
duplicate: "#6b7280",
|
||||
};
|
||||
return colors[status?.toLowerCase()] ?? "#6b7280";
|
||||
}
|
||||
|
||||
function statusIcon(status: string): keyof typeof Ionicons.glyphMap {
|
||||
const icons: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
completed: "checkmark-circle",
|
||||
processing: "sync-circle",
|
||||
pending: "time-outline",
|
||||
failed: "close-circle",
|
||||
duplicate: "copy-outline",
|
||||
};
|
||||
return icons[status?.toLowerCase()] ?? "document-outline";
|
||||
}
|
||||
|
||||
function logStepIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
|
||||
const lower = status?.toLowerCase();
|
||||
if (lower === "completed" || lower === "success") return { name: "checkmark-circle", color: "#059669" };
|
||||
if (lower === "failed" || lower === "error") return { name: "close-circle", color: "#dc2626" };
|
||||
if (lower === "skipped") return { name: "remove-circle-outline", color: "#9ca3af" };
|
||||
if (lower === "processing" || lower === "running") return { name: "sync-circle", color: "#d97706" };
|
||||
return { name: "ellipse-outline", color: "#6b7280" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function FileDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [detail, setDetail] = useState<FileDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fileId = parseInt(id ?? "0", 10);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
if (!fileId) return;
|
||||
try {
|
||||
const data = await api.getFileDetail(fileId);
|
||||
setDetail(data);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load file details");
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
await fetchDetail();
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [fetchDetail]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await fetchDetail();
|
||||
setRefreshing(false);
|
||||
}, [fetchDetail]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color="#1e40af" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error ?? "File not found"}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={handleRefresh}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={styles.backButtonText}>← Back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const file = detail.file;
|
||||
const status = detail.processing_status;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
|
||||
>
|
||||
{/* Header with back button */}
|
||||
<Pressable
|
||||
style={styles.backRow}
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Go back"
|
||||
>
|
||||
<Ionicons name="arrow-back" size={20} color="#1e40af" />
|
||||
<Text style={styles.backLabel}>Back to Files</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* File info card */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Ionicons
|
||||
name={statusIcon(status.status)}
|
||||
size={28}
|
||||
color={statusColor(status.status)}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.filename} numberOfLines={2}>
|
||||
{file.original_filename}
|
||||
</Text>
|
||||
<Text style={[styles.statusBadge, { color: statusColor(status.status) }]}>
|
||||
{status.status.charAt(0).toUpperCase() + status.status.slice(1)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.metaGrid}>
|
||||
<MetaRow label="File Size" value={formatBytes(file.file_size)} />
|
||||
<MetaRow label="MIME Type" value={file.mime_type ?? "–"} />
|
||||
<MetaRow label="Uploaded" value={formatDateTime(file.created_at)} />
|
||||
<MetaRow label="File Hash" value={file.filehash ? `${file.filehash.slice(0, 16)}…` : "–"} />
|
||||
<MetaRow label="Last Step" value={status.last_step ?? "–"} />
|
||||
<MetaRow label="Total Steps" value={String(status.total_steps)} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Processing logs */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>Processing Log</Text>
|
||||
{detail.logs.length === 0 ? (
|
||||
<Text style={styles.emptyLog}>No processing logs yet.</Text>
|
||||
) : (
|
||||
detail.logs.map((log, idx) => {
|
||||
const icon = logStepIcon(log.status);
|
||||
const isLast = idx === detail.logs.length - 1;
|
||||
return (
|
||||
<View key={log.id} style={[styles.logEntry, !isLast && styles.logEntryBorder]}>
|
||||
<Ionicons name={icon.name} size={18} color={icon.color} style={styles.logIcon} />
|
||||
<View style={styles.logContent}>
|
||||
<Text style={styles.logStep}>{log.step_name}</Text>
|
||||
<Text style={styles.logMessage} numberOfLines={3}>
|
||||
{log.message}
|
||||
</Text>
|
||||
<Text style={styles.logTimestamp}>{formatDateTime(log.timestamp)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{label}</Text>
|
||||
<Text style={styles.metaValue} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
content: { padding: 16, paddingBottom: 40 },
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
padding: 24,
|
||||
},
|
||||
errorText: { color: "#dc2626", fontSize: 15, textAlign: "center", marginBottom: 16 },
|
||||
retryButton: {
|
||||
backgroundColor: "#1e40af",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 10,
|
||||
marginBottom: 12,
|
||||
},
|
||||
retryText: { color: "#fff", fontWeight: "600" },
|
||||
backButton: { paddingVertical: 10 },
|
||||
backButtonText: { color: "#6b7280", fontSize: 14 },
|
||||
backRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
backLabel: {
|
||||
fontSize: 15,
|
||||
color: "#1e40af",
|
||||
fontWeight: "600",
|
||||
marginLeft: 6,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.04,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 16,
|
||||
},
|
||||
filename: {
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
metaGrid: {},
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f3f4f6",
|
||||
},
|
||||
metaLabel: { fontSize: 13, color: "#6b7280", fontWeight: "500" },
|
||||
metaValue: { fontSize: 13, color: "#374151", maxWidth: "55%", textAlign: "right" },
|
||||
sectionTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: "#374151",
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyLog: { fontSize: 13, color: "#9ca3af", fontStyle: "italic" },
|
||||
logEntry: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
logEntryBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f3f4f6",
|
||||
},
|
||||
logIcon: { marginRight: 10, marginTop: 1 },
|
||||
logContent: { flex: 1 },
|
||||
logStep: { fontSize: 13, fontWeight: "600", color: "#374151", marginBottom: 2 },
|
||||
logMessage: { fontSize: 12, color: "#6b7280", lineHeight: 17, marginBottom: 2 },
|
||||
logTimestamp: { fontSize: 11, color: "#9ca3af" },
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* FilesScreen – list of documents processed by DocuElevate.
|
||||
* FilesScreen – list of documents processed by DocuElevate with search.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import type { FileRecord } from "../services/api";
|
||||
@@ -47,17 +49,20 @@ function statusIcon(status: string): { name: keyof typeof Ionicons.glyphMap; col
|
||||
}
|
||||
|
||||
export default function FilesScreen() {
|
||||
const router = useRouter();
|
||||
const [files, setFiles] = useState<FileRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const fetchFiles = useCallback(
|
||||
async (pageNum: number, replace: boolean) => {
|
||||
async (pageNum: number, replace: boolean, search?: string) => {
|
||||
try {
|
||||
const data = await api.listFiles(pageNum, 20);
|
||||
const data = await api.listFiles(pageNum, 20, search || undefined);
|
||||
if (replace) {
|
||||
setFiles(data);
|
||||
} else {
|
||||
@@ -83,18 +88,49 @@ export default function FilesScreen() {
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
setPage(1);
|
||||
await fetchFiles(1, true);
|
||||
await fetchFiles(1, true, searchQuery);
|
||||
setRefreshing(false);
|
||||
}, [fetchFiles]);
|
||||
}, [fetchFiles, searchQuery]);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!hasMore || loading || refreshing) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
await fetchFiles(next, false);
|
||||
}, [fetchFiles, hasMore, loading, page, refreshing]);
|
||||
await fetchFiles(next, false, searchQuery);
|
||||
}, [fetchFiles, hasMore, loading, page, refreshing, searchQuery]);
|
||||
|
||||
if (loading) {
|
||||
const handleSearch = useCallback(
|
||||
(text: string) => {
|
||||
setSearchQuery(text);
|
||||
// Debounce search requests
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
setPage(1);
|
||||
setLoading(true);
|
||||
await fetchFiles(1, true, text);
|
||||
setLoading(false);
|
||||
}, 400);
|
||||
},
|
||||
[fetchFiles]
|
||||
);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
setPage(1);
|
||||
setLoading(true);
|
||||
fetchFiles(1, true).then(() => setLoading(false));
|
||||
}, [fetchFiles]);
|
||||
|
||||
const handleFilePress = useCallback(
|
||||
(file: FileRecord) => {
|
||||
router.push({ pathname: "/(tabs)/file-detail", params: { id: String(file.id) } });
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
if (loading && files.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color="#1e40af" />
|
||||
@@ -102,7 +138,7 @@ export default function FilesScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error && files.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
@@ -114,40 +150,77 @@ export default function FilesScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.list}
|
||||
data={files}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => <FileRow file={item} />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.4}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
|
||||
<Text style={styles.emptyText}>No documents yet.</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
Upload a document from the Upload tab to get started.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
hasMore && files.length > 0 ? (
|
||||
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<View style={styles.container}>
|
||||
{/* Search bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<Ionicons name="search-outline" size={18} color="#9ca3af" style={styles.searchIcon} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search documents…"
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
accessibilityLabel="Search documents"
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable
|
||||
onPress={handleClearSearch}
|
||||
style={styles.clearButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Clear search"
|
||||
>
|
||||
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
style={styles.list}
|
||||
data={files}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => <FileRow file={item} onPress={handleFilePress} />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.4}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
|
||||
<Text style={styles.emptyText}>
|
||||
{searchQuery ? "No documents match your search." : "No documents yet."}
|
||||
</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
{searchQuery
|
||||
? "Try a different search term."
|
||||
: "Upload a document from the Upload tab to get started."}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
hasMore && files.length > 0 ? (
|
||||
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ file }: { file: FileRecord }) {
|
||||
function FileRow({ file, onPress }: { file: FileRecord; onPress: (file: FileRecord) => void }) {
|
||||
const status = file.processing_status?.status ?? "pending";
|
||||
const icon = statusIcon(status);
|
||||
return (
|
||||
<View style={rowStyles.row}>
|
||||
<Pressable
|
||||
style={rowStyles.row}
|
||||
onPress={() => onPress(file)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`View details for ${file.original_filename}`}
|
||||
>
|
||||
<Ionicons name={icon.name} size={22} color={icon.color} style={rowStyles.icon} />
|
||||
<View style={rowStyles.info}>
|
||||
<Text style={rowStyles.filename} numberOfLines={1}>
|
||||
@@ -157,14 +230,44 @@ function FileRow({ file }: { file: FileRecord }) {
|
||||
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={rowStyles.status}>{status}</Text>
|
||||
</View>
|
||||
<View style={rowStyles.right}>
|
||||
<Text style={rowStyles.status}>{status}</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color="#d1d5db" />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
listContent: { padding: 16 },
|
||||
container: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
list: { flex: 1 },
|
||||
listContent: { padding: 16, paddingTop: 0 },
|
||||
searchContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 12,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
minHeight: 44,
|
||||
},
|
||||
searchIcon: { marginRight: 8 },
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: "#111827",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
clearButton: {
|
||||
padding: 4,
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
@@ -213,6 +316,11 @@ const rowStyles = StyleSheet.create({
|
||||
marginBottom: 4,
|
||||
},
|
||||
meta: { fontSize: 12, color: "#6b7280" },
|
||||
right: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
},
|
||||
status: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
|
||||
@@ -169,6 +169,45 @@ export default function LoginScreen() {
|
||||
>
|
||||
<Text style={styles.backLinkText}>← Back</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Legal links – accessible pre-login for GDPR / Apple compliance */}
|
||||
<View style={styles.legalLinks}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
const base = serverUrl.trim() || "https://app.docuelevate.org";
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/privacy`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Privacy Policy"
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Privacy Policy</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
const base = serverUrl.trim() || "https://app.docuelevate.org";
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/terms`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Terms of Service"
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Terms</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
const base = serverUrl.trim() || "https://app.docuelevate.org";
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/imprint`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Imprint"
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Imprint</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
@@ -291,4 +330,26 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
},
|
||||
legalLinks: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginTop: 16,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
legalLinkButton: {
|
||||
minHeight: 44,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
legalLinkText: {
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
legalSeparator: {
|
||||
fontSize: 12,
|
||||
color: "#d1d5db",
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import Constants from "expo-constants";
|
||||
import * as Linking from "expo-linking";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
@@ -15,14 +15,17 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { getLanguage, getSupportedLanguages, setLanguage } from "../i18n";
|
||||
|
||||
const DEFAULT_SERVER_URL = "https://app.docuelevate.org";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, signOut, baseUrl } = useAuth();
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(getLanguage());
|
||||
|
||||
const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL;
|
||||
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
||||
const languages = getSupportedLanguages();
|
||||
|
||||
function handleSignOut() {
|
||||
Alert.alert("Sign out", "Are you sure you want to sign out?", [
|
||||
@@ -66,6 +69,12 @@ export default function ProfileScreen() {
|
||||
});
|
||||
}
|
||||
|
||||
function openImprint() {
|
||||
Linking.openURL(`${effectiveBaseUrl}/imprint`).catch(() => {
|
||||
Alert.alert("Error", "Could not open the imprint page. Please try again.");
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
@@ -113,6 +122,39 @@ export default function ProfileScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Settings */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Settings</Text>
|
||||
<Text style={styles.settingLabel}>Language</Text>
|
||||
<View style={styles.languageGrid}>
|
||||
{languages.map((lang) => (
|
||||
<Pressable
|
||||
key={lang.code}
|
||||
style={[
|
||||
styles.languageChip,
|
||||
selectedLanguage === lang.code && styles.languageChipActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
setLanguage(lang.code);
|
||||
setSelectedLanguage(lang.code);
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Set language to ${lang.label}`}
|
||||
accessibilityState={{ selected: selectedLanguage === lang.code }}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.languageChipText,
|
||||
selectedLanguage === lang.code && styles.languageChipTextActive,
|
||||
]}
|
||||
>
|
||||
{lang.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Legal & Privacy */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Legal</Text>
|
||||
@@ -134,6 +176,15 @@ export default function ProfileScreen() {
|
||||
<Text style={styles.linkText}>Terms of Service</Text>
|
||||
<Text style={styles.linkChevron}>›</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.linkRow, styles.linkRowLast]}
|
||||
onPress={openImprint}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Imprint"
|
||||
>
|
||||
<Text style={styles.linkText}>Imprint</Text>
|
||||
<Text style={styles.linkChevron}>›</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Sign out */}
|
||||
@@ -264,6 +315,9 @@ const styles = StyleSheet.create({
|
||||
borderBottomColor: "#f3f4f6",
|
||||
minHeight: 44,
|
||||
},
|
||||
linkRowLast: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 15,
|
||||
color: "#1e40af",
|
||||
@@ -305,4 +359,38 @@ const styles = StyleSheet.create({
|
||||
textAlign: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
settingLabel: {
|
||||
fontSize: 14,
|
||||
color: "#374151",
|
||||
fontWeight: "500",
|
||||
marginBottom: 10,
|
||||
},
|
||||
languageGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
},
|
||||
languageChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
minHeight: 36,
|
||||
justifyContent: "center",
|
||||
},
|
||||
languageChipActive: {
|
||||
backgroundColor: "#dbeafe",
|
||||
borderColor: "#1e40af",
|
||||
},
|
||||
languageChipText: {
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
fontWeight: "500",
|
||||
},
|
||||
languageChipTextActive: {
|
||||
color: "#1e40af",
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -301,14 +301,16 @@ export default function UploadScreen() {
|
||||
mediaTypes: ["images"],
|
||||
quality: 0.9,
|
||||
allowsEditing: false,
|
||||
allowsMultipleSelection: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets.length > 0) {
|
||||
const asset = result.assets[0];
|
||||
// Derive extension from MIME type so the filename matches the actual format
|
||||
const ext = asset.mimeType?.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
|
||||
const filename = asset.fileName ?? `photo_${Date.now()}.${ext}`;
|
||||
await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
|
||||
for (const asset of result.assets) {
|
||||
// Derive extension from MIME type so the filename matches the actual format
|
||||
const ext = asset.mimeType?.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
|
||||
const filename = asset.fileName ?? `photo_${Date.now()}_${Math.random().toString(36).slice(2, 6)}.${ext}`;
|
||||
await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useRouter } from "expo-router";
|
||||
import * as Linking from "expo-linking";
|
||||
import React from "react";
|
||||
import {
|
||||
Image,
|
||||
@@ -87,6 +88,36 @@ export default function WelcomeScreen() {
|
||||
<Text style={styles.hint}>
|
||||
Connect to your self-hosted or cloud DocuElevate server.
|
||||
</Text>
|
||||
|
||||
{/* Legal links – accessible pre-login for GDPR / Apple compliance */}
|
||||
<View style={styles.legalLinks}>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://app.docuelevate.org/privacy")}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Privacy Policy"
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Privacy Policy</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://app.docuelevate.org/terms")}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Terms of Service"
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Terms</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://app.docuelevate.org/imprint")}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Imprint"
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Imprint</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -206,4 +237,26 @@ const styles = StyleSheet.create({
|
||||
color: "rgba(255,255,255,0.55)",
|
||||
textAlign: "center",
|
||||
},
|
||||
legalLinks: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginTop: 20,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
legalLinkButton: {
|
||||
minHeight: 44,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
legalLinkText: {
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.65)",
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
legalSeparator: {
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.45)",
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,6 +78,32 @@ export interface UploadResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProcessingLog {
|
||||
id: number;
|
||||
task_id: string;
|
||||
step_name: string;
|
||||
status: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface FileDetail {
|
||||
file: {
|
||||
id: number;
|
||||
filehash: string;
|
||||
original_filename: string;
|
||||
local_filename: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
created_at: string;
|
||||
};
|
||||
processing_status: ProcessingStatus;
|
||||
logs: ProcessingLog[];
|
||||
files_on_disk: {
|
||||
original: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base API client
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -229,6 +255,11 @@ class DocuElevateAPI {
|
||||
);
|
||||
return data.processing_status;
|
||||
}
|
||||
|
||||
/** Get full file details including processing logs. */
|
||||
async getFileDetail(fileId: number): Promise<FileDetail> {
|
||||
return this.request<FileDetail>("GET", `/api/files/${fileId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new DocuElevateAPI();
|
||||
|
||||
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
|
||||
".tif",
|
||||
".webp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
}
|
||||
_html_extensions = {".html", ".htm"}
|
||||
_markdown_extensions = {".md", ".markdown"}
|
||||
|
||||
Reference in New Issue
Block a user