diff --git a/app/api/files.py b/app/api/files.py index 9d7d71da..8eae6e55 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -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) diff --git a/app/tasks/convert_to_pdf.py b/app/tasks/convert_to_pdf.py index 2a0ff4ac..08320f55 100644 --- a/app/tasks/convert_to_pdf.py +++ b/app/tasks/convert_to_pdf.py @@ -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"} diff --git a/app/utils/allowed_types.py b/app/utils/allowed_types.py index eaa37e6e..7212c0f8 100644 --- a/app/utils/allowed_types.py +++ b/app/utils/allowed_types.py @@ -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", } ), }, diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 7fac8c3b..1a577fd4 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -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 " 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 ``` diff --git a/docs/UserGuide.md b/docs/UserGuide.md index cd8afdd1..6a981126 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -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 diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index c115f7de..eb2c93dd 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -67,6 +67,15 @@ export default function TabLayout() { headerTitle: "Profile", }} /> + {/* File detail screen – hidden from tab bar, accessed via navigation */} + ); } diff --git a/mobile/app/(tabs)/file-detail.tsx b/mobile/app/(tabs)/file-detail.tsx new file mode 100644 index 00000000..ad30c207 --- /dev/null +++ b/mobile/app/(tabs)/file-detail.tsx @@ -0,0 +1,4 @@ +/** + * File detail route – displays processing status and logs for a single file. + */ +export { default } from "../../src/screens/FileDetailScreen"; diff --git a/mobile/package.json b/mobile/package.json index bc62426c..9b487af2 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/mobile/src/i18n/de.json b/mobile/src/i18n/de.json new file mode 100644 index 00000000..5a107e4c --- /dev/null +++ b/mobile/src/i18n/de.json @@ -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" + } +} diff --git a/mobile/src/i18n/en.json b/mobile/src/i18n/en.json new file mode 100644 index 00000000..342f26bd --- /dev/null +++ b/mobile/src/i18n/en.json @@ -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" + } +} diff --git a/mobile/src/i18n/es.json b/mobile/src/i18n/es.json new file mode 100644 index 00000000..57216302 --- /dev/null +++ b/mobile/src/i18n/es.json @@ -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" + } +} diff --git a/mobile/src/i18n/fr.json b/mobile/src/i18n/fr.json new file mode 100644 index 00000000..2dce9a8f --- /dev/null +++ b/mobile/src/i18n/fr.json @@ -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" + } +} diff --git a/mobile/src/i18n/index.ts b/mobile/src/i18n/index.ts new file mode 100644 index 00000000..36aedfe3 --- /dev/null +++ b/mobile/src/i18n/index.ts @@ -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>; + +const translations: Record = { 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 { + 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" }, + ]; +} diff --git a/mobile/src/i18n/it.json b/mobile/src/i18n/it.json new file mode 100644 index 00000000..a3211a5c --- /dev/null +++ b/mobile/src/i18n/it.json @@ -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" + } +} diff --git a/mobile/src/screens/FileDetailScreen.tsx b/mobile/src/screens/FileDetailScreen.tsx new file mode 100644 index 00000000..408f3687 --- /dev/null +++ b/mobile/src/screens/FileDetailScreen.tsx @@ -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 = { + 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 = { + 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(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(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 ( + + + + ); + } + + if (error || !detail) { + return ( + + {error ?? "File not found"} + + Retry + + router.back()}> + ← Back + + + ); + } + + const file = detail.file; + const status = detail.processing_status; + + return ( + } + > + {/* Header with back button */} + router.back()} + accessibilityRole="button" + accessibilityLabel="Go back" + > + + Back to Files + + + {/* File info card */} + + + + + + {file.original_filename} + + + {status.status.charAt(0).toUpperCase() + status.status.slice(1)} + + + + + + + + + + + + + + + {/* Processing logs */} + + Processing Log + {detail.logs.length === 0 ? ( + No processing logs yet. + ) : ( + detail.logs.map((log, idx) => { + const icon = logStepIcon(log.status); + const isLast = idx === detail.logs.length - 1; + return ( + + + + {log.step_name} + + {log.message} + + {formatDateTime(log.timestamp)} + + + ); + }) + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function MetaRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} + +// --------------------------------------------------------------------------- +// 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" }, +}); diff --git a/mobile/src/screens/FilesScreen.tsx b/mobile/src/screens/FilesScreen.tsx index 191904c6..78f727dc 100644 --- a/mobile/src/screens/FilesScreen.tsx +++ b/mobile/src/screens/FilesScreen.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const searchTimeoutRef = useRef | 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 ( @@ -102,7 +138,7 @@ export default function FilesScreen() { ); } - if (error) { + if (error && files.length === 0) { return ( {error} @@ -114,40 +150,77 @@ export default function FilesScreen() { } return ( - String(item.id)} - contentContainerStyle={styles.listContent} - renderItem={({ item }) => } - refreshControl={ - - } - onEndReached={handleLoadMore} - onEndReachedThreshold={0.4} - ListEmptyComponent={ - - - No documents yet. - - Upload a document from the Upload tab to get started. - - - } - ListFooterComponent={ - hasMore && files.length > 0 ? ( - - ) : null - } - /> + + {/* Search bar */} + + + + {searchQuery.length > 0 && ( + + + + )} + + + String(item.id)} + contentContainerStyle={styles.listContent} + renderItem={({ item }) => } + refreshControl={ + + } + onEndReached={handleLoadMore} + onEndReachedThreshold={0.4} + ListEmptyComponent={ + + + + {searchQuery ? "No documents match your search." : "No documents yet."} + + + {searchQuery + ? "Try a different search term." + : "Upload a document from the Upload tab to get started."} + + + } + ListFooterComponent={ + hasMore && files.length > 0 ? ( + + ) : null + } + /> + ); } -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 ( - + onPress(file)} + accessibilityRole="button" + accessibilityLabel={`View details for ${file.original_filename}`} + > @@ -157,14 +230,44 @@ function FileRow({ file }: { file: FileRecord }) { {formatDate(file.created_at)} · {formatBytes(file.file_size)} - {status} - + + {status} + + + ); } 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", diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx index 80aabfd0..31a35784 100644 --- a/mobile/src/screens/LoginScreen.tsx +++ b/mobile/src/screens/LoginScreen.tsx @@ -169,6 +169,45 @@ export default function LoginScreen() { > ← Back + + {/* Legal links – accessible pre-login for GDPR / Apple compliance */} + + { + const base = serverUrl.trim() || "https://app.docuelevate.org"; + Linking.openURL(`${base.replace(/\/$/, "")}/privacy`); + }} + accessibilityRole="link" + accessibilityLabel="Privacy Policy" + style={styles.legalLinkButton} + > + Privacy Policy + + · + { + const base = serverUrl.trim() || "https://app.docuelevate.org"; + Linking.openURL(`${base.replace(/\/$/, "")}/terms`); + }} + accessibilityRole="link" + accessibilityLabel="Terms of Service" + style={styles.legalLinkButton} + > + Terms + + · + { + const base = serverUrl.trim() || "https://app.docuelevate.org"; + Linking.openURL(`${base.replace(/\/$/, "")}/imprint`); + }} + accessibilityRole="link" + accessibilityLabel="Imprint" + style={styles.legalLinkButton} + > + Imprint + + ); @@ -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, + }, }); diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index 48146fc3..6c4bb866 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -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 ( @@ -113,6 +122,39 @@ export default function ProfileScreen() { + {/* Settings */} + + Settings + Language + + {languages.map((lang) => ( + { + setLanguage(lang.code); + setSelectedLanguage(lang.code); + }} + accessibilityRole="button" + accessibilityLabel={`Set language to ${lang.label}`} + accessibilityState={{ selected: selectedLanguage === lang.code }} + > + + {lang.label} + + + ))} + + + {/* Legal & Privacy */} Legal @@ -134,6 +176,15 @@ export default function ProfileScreen() { Terms of Service + + Imprint + + {/* 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", + }, }); diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index 07a1b97e..eeb40939 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -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"); + } } } diff --git a/mobile/src/screens/WelcomeScreen.tsx b/mobile/src/screens/WelcomeScreen.tsx index f779456c..b56a91c0 100644 --- a/mobile/src/screens/WelcomeScreen.tsx +++ b/mobile/src/screens/WelcomeScreen.tsx @@ -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() { Connect to your self-hosted or cloud DocuElevate server. + + {/* Legal links – accessible pre-login for GDPR / Apple compliance */} + + Linking.openURL("https://app.docuelevate.org/privacy")} + accessibilityRole="link" + accessibilityLabel="Privacy Policy" + style={styles.legalLinkButton} + > + Privacy Policy + + · + Linking.openURL("https://app.docuelevate.org/terms")} + accessibilityRole="link" + accessibilityLabel="Terms of Service" + style={styles.legalLinkButton} + > + Terms + + · + Linking.openURL("https://app.docuelevate.org/imprint")} + accessibilityRole="link" + accessibilityLabel="Imprint" + style={styles.legalLinkButton} + > + Imprint + + ); @@ -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, + }, }); diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts index 9159a1ff..1e0b41ea 100644 --- a/mobile/src/services/api.ts +++ b/mobile/src/services/api.ts @@ -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 { + return this.request("GET", `/api/files/${fileId}`); + } } export const api = new DocuElevateAPI(); diff --git a/tests/test_allowed_types.py b/tests/test_allowed_types.py index 23ad5f37..5802e5b2 100644 --- a/tests/test_allowed_types.py +++ b/tests/test_allowed_types.py @@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments: ".tif", ".webp", ".svg", + ".heic", + ".heif", } _html_extensions = {".html", ".htm"} _markdown_extensions = {".md", ".markdown"}