fix: merge main branch and renumber migration 027→037
Resolve 3 merge conflicts and renumber the automation_hooks migration to follow main's migration chain (036_add_document_translation_fields). Conflicts resolved: - app/api/__init__.py: add automation_router alongside main's new routers - app/utils/settings_service.py: add automation_hooks_enabled alongside compliance_enabled - tests/conftest.py: add AutomationHook alongside AuditLog/ComplianceTemplate imports Migration renumbered: - 027_add_automation_hooks → 037_add_automation_hooks - down_revision: 026_add_scheduled_jobs → 036_add_document_translation_fields Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
name: Build and submit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- mobile/**
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
name: Build Android app
|
||||
type: build
|
||||
env:
|
||||
# Suppress Node.js url.parse() deprecation warnings ([DEP0169]) emitted by
|
||||
# EAS CLI when running on the build image's system Node 22+.
|
||||
NODE_NO_WARNINGS: "1"
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
|
||||
build_ios:
|
||||
name: Build iOS app
|
||||
type: build
|
||||
env:
|
||||
# Suppress Node.js url.parse() deprecation warnings ([DEP0169]) emitted by
|
||||
# EAS CLI when running on the build image's system Node 22+.
|
||||
NODE_NO_WARNINGS: "1"
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit_ios:
|
||||
name: Submit iOS to App Store Connect
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
@@ -0,0 +1,21 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
ios/
|
||||
android/
|
||||
.env
|
||||
google-services.json
|
||||
GoogleService-Info.plist
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1 @@
|
||||
20.19.4
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* App.tsx – legacy root component (not the active entry point).
|
||||
*
|
||||
* The app uses expo-router as its entry point ("main": "expo-router/entry"
|
||||
* in package.json). Routing, authentication, and navigation are all handled
|
||||
* by the file-based routes in the `app/` directory.
|
||||
*
|
||||
* This file is retained for reference only and is not executed at runtime.
|
||||
*/
|
||||
@@ -0,0 +1,237 @@
|
||||
# DocuElevate Mobile App
|
||||
|
||||
Native mobile application for DocuElevate, built with **React Native** and **Expo** for iOS, Android, and Web.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **SSO Login** – authenticate via your DocuElevate server's OAuth2/SSO provider; an API token is auto-generated and stored securely in the device keychain
|
||||
- 📷 **Camera Capture** – scan documents directly with the device camera
|
||||
- 🖼️ **Photo Library** – select existing photos from the device's photo library for upload
|
||||
- 📄 **File Picker** – upload PDFs, images, and Office documents from the device's Files app
|
||||
- 🔗 **Share Extension** – send files from any app directly to DocuElevate via the iOS/Android share sheet
|
||||
- 🔔 **Push Notifications** – receive real-time push notifications when documents finish processing (via Expo push notifications)
|
||||
- 📂 **Document List** – browse and search your processed documents
|
||||
- 👤 **Profile** – view account details and sign out
|
||||
- 🌐 **Web** – run directly in the browser via Expo web (Metro bundler)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20.19.4+ (use [nvm](https://github.com/nvm-sh/nvm): `nvm use` in this directory)
|
||||
- Expo CLI (`npm install -g @expo/cli`)
|
||||
- Expo Go app on device (for development) **or** Expo Application Services (EAS) for production builds
|
||||
- An Expo account: <https://expo.dev/>
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
cd mobile
|
||||
npm install
|
||||
|
||||
# 2. Start the development server (choose a platform)
|
||||
npx expo start # interactive menu (iOS / Android / Web)
|
||||
npx expo start --ios # open directly in iOS Simulator
|
||||
npx expo start --android # open in Android Emulator
|
||||
npx expo start --web # open in the browser
|
||||
```
|
||||
|
||||
Scan the QR code with **Expo Go** on your iOS or Android device, or press `w` in the interactive menu to open the web build.
|
||||
|
||||
## Building
|
||||
|
||||
DocuElevate uses **EAS Build** for production binaries.
|
||||
|
||||
```bash
|
||||
# Install EAS CLI
|
||||
npm install -g eas-cli
|
||||
|
||||
# Log in to Expo
|
||||
eas login
|
||||
|
||||
# Build for iOS
|
||||
eas build --platform ios
|
||||
|
||||
# Build for Android
|
||||
eas build --platform android
|
||||
|
||||
# Build for both
|
||||
eas build --platform all
|
||||
```
|
||||
|
||||
> **Note:** The EAS project ID is already configured in `app.json` (`extra.eas.projectId`). You only need to run `eas init` if you are setting up a fork or a brand-new EAS project — in that case, replace the `extra.eas.projectId` value in `app.json` with the ID printed by `eas init`.
|
||||
|
||||
### iOS-specific
|
||||
|
||||
- An Apple Developer account is required for TestFlight and App Store distribution
|
||||
- Update `eas.json` → `submit.production.ios` with:
|
||||
- `appleId`: your Apple ID email address
|
||||
- `ascAppId`: App Store Connect → App Information → Apple ID
|
||||
- `appleTeamId`: Apple Developer portal → Membership → Team ID
|
||||
- Camera, photo library, and push notification usage descriptions are configured in `app.json`
|
||||
|
||||
### Android-specific
|
||||
|
||||
- **Android push notifications** require a `google-services.json` file from Firebase Console. This file is intentionally excluded from the repository (`.gitignore`). To enable FCM push notifications in your Android builds:
|
||||
1. Create a Firebase project at <https://console.firebase.google.com/>
|
||||
2. Add an Android app with the package name `org.docuelevate.mobile`
|
||||
3. Download `google-services.json` and place it in the `mobile/` directory
|
||||
4. Add `"googleServicesFile": "./google-services.json"` back to the `android` section of `app.json` before building
|
||||
- The app runs and bundles correctly without `google-services.json`; only Android push notifications will be unavailable
|
||||
- For Play Store submission: create a service account in Google Play Console, download the JSON key as `google-play-service-account.json`, and update `eas.json`
|
||||
|
||||
## CI/CD
|
||||
|
||||
An EAS Cloud Workflow (`mobile/.eas/workflows/create-builds.yml`) runs automatically when changes inside `mobile/` are pushed to `main`:
|
||||
|
||||
1. **Path filtering** — only commits that modify files under `mobile/` trigger a build; backend-only changes are skipped.
|
||||
2. **Parallel builds** — iOS and Android production builds run at the same time on EAS Build.
|
||||
3. **Auto-submit to Apple** — after the iOS build succeeds, the workflow submits the binary to App Store Connect (TestFlight) using the credentials in `eas.json` → `submit.production.ios`.
|
||||
|
||||
> An [App Store Connect API Key](https://docs.expo.dev/app-signing/app-credentials/#app-store-connect-api-key) must be configured in EAS (`eas credentials`) for non-interactive submission.
|
||||
|
||||
### Version management
|
||||
|
||||
Build numbers (`ios.buildNumber` / `android.versionCode`) are managed **remotely** by EAS — see `eas.json`:
|
||||
|
||||
- `"appVersionSource": "remote"` — EAS tracks the current build number on its servers, so each CI build automatically receives a unique, incrementing number without committing changes back to the repo.
|
||||
- `"autoIncrement": true` (production profile) — EAS bumps the build number before every production build.
|
||||
|
||||
The values in `app.json` are used as the **initial seed** when the remote version is first created; after that they are informational only. Use `eas build:version:get` / `eas build:version:set` to inspect or override the remote version.
|
||||
|
||||
## Configuration
|
||||
|
||||
No code changes are needed to point the app at a different server. The server URL is entered by the user on the login screen and stored in the device's secure store.
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. User enters the DocuElevate server URL on the login screen
|
||||
2. The app opens the server's `/login?mobile=1&redirect_uri=docuelevate://callback` URL in the system browser
|
||||
3. The user authenticates (SSO / local login)
|
||||
4. The server redirects back to `docuelevate://callback`
|
||||
5. The app exchanges the browser session for a permanent API token via `POST /api/mobile/generate-token`
|
||||
6. The token is stored in the device's secure keychain (`expo-secure-store`)
|
||||
|
||||
## Push Notifications
|
||||
|
||||
The app uses **Expo Push Notifications** which route through Expo's servers to APNs (iOS) and FCM (Android) – no server-side APNs/FCM credentials are needed.
|
||||
|
||||
The Expo push token is sent to the backend after login via `POST /api/mobile/register-device` and the server uses it to deliver notifications when documents are processed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mobile/
|
||||
├── app/ # expo-router file-based routes
|
||||
│ ├── _layout.tsx # Root layout (AuthProvider + auth guard)
|
||||
│ ├── (auth)/ # Unauthenticated route group
|
||||
│ │ ├── _layout.tsx # Auth stack (no header)
|
||||
│ │ ├── index.tsx # Welcome screen
|
||||
│ │ └── login.tsx # Login screen
|
||||
│ └── (tabs)/ # Authenticated route group
|
||||
│ ├── _layout.tsx # Tab navigator (Upload / Files / Profile)
|
||||
│ ├── index.tsx # Upload tab
|
||||
│ ├── files.tsx # Files tab
|
||||
│ └── profile.tsx # Profile tab
|
||||
├── App.tsx # Legacy file (not the entry point; see app/)
|
||||
├── app.json # Expo configuration
|
||||
├── eas.json # EAS Build configuration
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
├── context/
|
||||
│ ├── AuthContext.tsx # Authentication state management
|
||||
│ └── ShareContext.tsx # Shared-file queue (iOS Share Sheet / Android Intent)
|
||||
├── hooks/
|
||||
│ └── usePushNotifications.ts # Push notification registration
|
||||
├── screens/
|
||||
│ ├── WelcomeScreen.tsx # Branded intro / onboarding
|
||||
│ ├── LoginScreen.tsx # SSO login
|
||||
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
|
||||
│ ├── FilesScreen.tsx # Document list
|
||||
│ └── ProfileScreen.tsx # User profile + sign out
|
||||
└── services/
|
||||
└── api.ts # DocuElevate API client
|
||||
```
|
||||
|
||||
## Share Sheet (iOS) / Share Intent (Android)
|
||||
|
||||
The app registers itself as a share target so any file can be sent directly to DocuElevate from another app.
|
||||
|
||||
### iOS – how it works
|
||||
|
||||
`app.json` declares `CFBundleDocumentTypes` in the iOS `infoPlist` section. This tells iOS which file types the app can receive, causing it to appear in the share sheet when the user shares a matching file. When the user taps **DocuElevate** in the share sheet, iOS passes the file path to the app via `application:openURL:options:`. The URL may arrive as a standard `file://` path or under the app's custom `docuelevate://` scheme.
|
||||
|
||||
The root layout (`app/_layout.tsx`) listens for incoming URLs via `Linking.addEventListener` (warm start) and `Linking.getInitialURL()` (cold start). If the URL uses the `docuelevate://` scheme it is automatically rewritten to `file://` before being forwarded. Incoming files are stored in `ShareContext` and automatically uploaded by `UploadScreen`.
|
||||
|
||||
**Supported iOS file types:** PDF, images (JPEG / PNG / GIF / BMP / TIFF / WebP), plain text, Word (`.docx`, `.doc`), Excel (`.xlsx`, `.xls`), PowerPoint (`.pptx`, `.ppt`), and any other file (`public.data`).
|
||||
|
||||
To use the share sheet:
|
||||
|
||||
1. Ensure the app is installed on the device.
|
||||
2. Open any supported file in Files, Mail, Safari, etc.
|
||||
3. Tap the **Share** button → find **DocuElevate** in the share sheet.
|
||||
4. The file is uploaded immediately.
|
||||
|
||||
> **Note:** `CFBundleDocumentTypes` with `LSHandlerRank: Alternate` means DocuElevate appears in the share sheet as an option but does **not** become the default app for any file type.
|
||||
|
||||
### Android – how it works
|
||||
|
||||
`app.json` declares `intentFilters` for `ACTION_SEND` and `ACTION_SEND_MULTIPLE` with `mimeType: "*/*"`. When a user shares a file from another app and selects DocuElevate, Android delivers the content URI through the share intent, which is captured via `Linking.getInitialURL()` and processed the same way as on iOS.
|
||||
|
||||
## Backend API
|
||||
|
||||
The mobile app uses the following backend endpoints:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|----------|-------------------------------------|---------------------------------------|
|
||||
| `POST` | `/api/mobile/generate-token` | Exchange SSO session for API token |
|
||||
| `POST` | `/api/mobile/register-device` | Register Expo push token |
|
||||
| `GET` | `/api/mobile/devices` | List registered devices |
|
||||
| `DELETE` | `/api/mobile/devices/{id}` | Deactivate device registration |
|
||||
| `GET` | `/api/mobile/whoami` | Get current user profile |
|
||||
| `POST` | `/api/ui-upload` | Upload file for processing |
|
||||
| `GET` | `/api/files` | List processed documents |
|
||||
| `GET` | `/api/files/{id}` | Get processing status of a single file |
|
||||
|
||||
Authentication uses `Authorization: Bearer <api_token>` on all requests.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `Session expired Local session` when running `eas build`
|
||||
|
||||
EAS CLI stores an Apple ID session locally to manage provisioning profiles and code-signing certificates. This session expires after several weeks.
|
||||
|
||||
**Quick fix (local):** Re-authenticate by running:
|
||||
|
||||
```bash
|
||||
eas credentials
|
||||
```
|
||||
|
||||
Select iOS → re-enter your Apple ID credentials when prompted.
|
||||
|
||||
**Recommended (CI / automation):** Switch to an [App Store Connect API Key](https://docs.expo.dev/app-signing/app-credentials/#app-store-connect-api-key) which does not expire automatically and works non-interactively:
|
||||
|
||||
1. Go to [appstoreconnect.apple.com → Users → Integrations → Keys](https://appstoreconnect.apple.com/access/integrations/api) and create a key with *Developer* or *App Manager* role.
|
||||
2. Download the `.p8` file; note the **Key ID** and **Issuer ID**.
|
||||
3. Run `eas credentials` → iOS → *Add an App Store Connect API key* and upload the `.p8` file.
|
||||
|
||||
Once an API key is stored in EAS, all future builds (local and CI) will use it automatically without requiring an Apple ID session.
|
||||
|
||||
### `[DEP0169] DeprecationWarning: url.parse()` during build
|
||||
|
||||
```text
|
||||
(node:XXXXX) [DEP0169] DeprecationWarning: `url.parse()` behavior is not standardized…
|
||||
```
|
||||
|
||||
This is emitted by EAS CLI itself when it runs on **Node.js 22 or later**, which deprecates `url.parse()`. It is a warning only and does not cause build failures on its own. The `eas.json` build profiles already suppress it via `"NODE_NO_WARNINGS": "1"` in their `env` sections.
|
||||
|
||||
To suppress the warning locally, either:
|
||||
|
||||
```bash
|
||||
# Option A: Run with the warning suppressed
|
||||
NODE_NO_WARNINGS=1 eas build --platform ios
|
||||
|
||||
# Option B: Switch to the pinned Node version (no warning on Node 20)
|
||||
nvm use # reads .nvmrc → Node 20.19.4
|
||||
eas build --platform ios
|
||||
```
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "DocuElevate",
|
||||
"slug": "docuelevate-mobile",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#1e40af"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "org.docuelevate.mobile",
|
||||
"appleTeamId": "975U2ZESBM",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "DocuElevate uses the camera to capture documents for upload.",
|
||||
"NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.",
|
||||
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
|
||||
"UIBackgroundModes": ["fetch", "remote-notification"],
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"LSSupportsOpeningDocumentsInPlace": true,
|
||||
"CFBundleDocumentTypes": [
|
||||
{
|
||||
"CFBundleTypeName": "All Documents",
|
||||
"CFBundleTypeRole": "Viewer",
|
||||
"LSHandlerRank": "Alternate",
|
||||
"LSItemContentTypes": [
|
||||
"public.content",
|
||||
"public.data",
|
||||
"public.image",
|
||||
"com.adobe.pdf",
|
||||
"public.plain-text",
|
||||
"org.openxmlformats.wordprocessingml.document",
|
||||
"org.openxmlformats.spreadsheetml.sheet",
|
||||
"org.openxmlformats.presentationml.presentation",
|
||||
"com.microsoft.word.doc",
|
||||
"com.microsoft.excel.xls",
|
||||
"com.microsoft.powerpoint.ppt"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"buildNumber": "7"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#1e40af"
|
||||
},
|
||||
"package": "org.docuelevate.mobile",
|
||||
"permissions": [
|
||||
"CAMERA",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"WRITE_EXTERNAL_STORAGE",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"VIBRATE"
|
||||
],
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "SEND",
|
||||
"data": [{ "mimeType": "*/*" }],
|
||||
"category": ["DEFAULT"]
|
||||
},
|
||||
{
|
||||
"action": "SEND_MULTIPLE",
|
||||
"data": [{ "mimeType": "*/*" }],
|
||||
"category": ["DEFAULT"]
|
||||
}
|
||||
],
|
||||
"versionCode": 7
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png",
|
||||
"bundler": "metro",
|
||||
"output": "single"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"buildReactNativeFromSource": true
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/notification-icon.png",
|
||||
"color": "#1e40af",
|
||||
"sounds": ["./assets/notification_sound.wav"]
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "DocuElevate uses the camera to capture documents for upload."
|
||||
}
|
||||
],
|
||||
"expo-document-picker",
|
||||
"expo-secure-store"
|
||||
],
|
||||
"scheme": "docuelevate",
|
||||
"owner": "christianlouis",
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "16925679-cb94-411c-83b5-a62c9addb872"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Layout for the unauthenticated route group: Welcome → Login.
|
||||
*
|
||||
* Uses a headerless native stack so the screens animate naturally.
|
||||
*/
|
||||
|
||||
import { Stack } from "expo-router";
|
||||
import React from "react";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="login" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Welcome screen route – first screen shown to unauthenticated users.
|
||||
*/
|
||||
export { default } from "../../src/screens/WelcomeScreen";
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Login screen route – server URL entry and SSO sign-in.
|
||||
*/
|
||||
export { default } from "../../src/screens/LoginScreen";
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Tab navigator layout for authenticated users.
|
||||
*
|
||||
* Registers three tabs: Upload (default), Files, and Profile.
|
||||
* Push notifications are initialised here so they activate as soon as the
|
||||
* user enters the authenticated area.
|
||||
*/
|
||||
|
||||
import { Tabs } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { usePushNotifications } from "../../src/hooks/usePushNotifications";
|
||||
import { useAuth } from "../../src/context/AuthContext";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
usePushNotifications(isAuthenticated);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: "#1e40af",
|
||||
tabBarInactiveTintColor: "#9ca3af",
|
||||
tabBarStyle: {
|
||||
borderTopColor: "#e5e7eb",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: "#1e40af",
|
||||
},
|
||||
headerTintColor: "#ffffff",
|
||||
headerTitleStyle: {
|
||||
fontWeight: "700",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Upload",
|
||||
tabBarLabel: "Upload",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="cloud-upload-outline" size={size} color={color} />
|
||||
),
|
||||
headerTitle: "DocuElevate",
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="files"
|
||||
options={{
|
||||
title: "Files",
|
||||
tabBarLabel: "Files",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="document-text-outline" size={size} color={color} />
|
||||
),
|
||||
headerTitle: "My Documents",
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: "Profile",
|
||||
tabBarLabel: "Profile",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="person-circle-outline" size={size} color={color} />
|
||||
),
|
||||
headerTitle: "Profile",
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Files tab route.
|
||||
*/
|
||||
export { default } from "../../src/screens/FilesScreen";
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Upload tab route (default tab for authenticated users).
|
||||
*/
|
||||
export { default } from "../../src/screens/UploadScreen";
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Profile tab route.
|
||||
*/
|
||||
export { default } from "../../src/screens/ProfileScreen";
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Root layout for the DocuElevate mobile app.
|
||||
*
|
||||
* Wraps the entire app in AuthProvider and SafeAreaProvider, then uses the
|
||||
* AuthGuard component to redirect between the unauthenticated (auth) route
|
||||
* group and the authenticated (tabs) route group based on session state.
|
||||
*
|
||||
* ShareProvider + Linking listener: when iOS opens the app via the share
|
||||
* sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming
|
||||
* file:// / content:// URL is captured and forwarded to UploadScreen via
|
||||
* ShareContext.
|
||||
*/
|
||||
|
||||
import * as Linking from "expo-linking";
|
||||
import { Stack, useRouter, useSegments } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { AuthProvider, useAuth } from "../src/context/AuthContext";
|
||||
import { ShareProvider, useShare } from "../src/context/ShareContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The custom URL scheme registered in app.json. */
|
||||
const APP_SCHEME_PREFIX = "docuelevate://";
|
||||
|
||||
/** Extract a display filename from a file:// or content:// URI. */
|
||||
function filenameFromUri(uri: string): string {
|
||||
try {
|
||||
const decoded = decodeURIComponent(uri);
|
||||
// Take the last path segment and strip any query string
|
||||
const last = decoded.split("/").pop() ?? "shared_file";
|
||||
return last.split("?")[0] || "shared_file";
|
||||
} catch {
|
||||
return "shared_file";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Linking URL handler that forwards incoming file:// / content://
|
||||
* URLs to ShareContext. Extracted as a module-level factory so the handler
|
||||
* itself is created once and can be easily unit-tested without a React context.
|
||||
*
|
||||
* On iOS the Share Sheet / "Open In" action may deliver the file path under
|
||||
* the app's custom URL scheme (`docuelevate://…/file.pdf`) instead of a plain
|
||||
* `file://` URL. When that happens we rewrite the URL to `file:///…` so the
|
||||
* upload logic can read the file normally.
|
||||
*/
|
||||
function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) {
|
||||
return ({ url }: { url: string }) => {
|
||||
let fileUri = url;
|
||||
|
||||
// iOS may pass a filesystem path under the app's custom scheme.
|
||||
// Rewrite it to a file:// URL unless it looks like an in-app deep-link
|
||||
// (expo-router groups always start with "(").
|
||||
if (url.startsWith(APP_SCHEME_PREFIX)) {
|
||||
const path = url.slice(APP_SCHEME_PREFIX.length);
|
||||
if (path.length > 0 && !path.startsWith("(")) {
|
||||
fileUri = "file:///" + path.replace(/^\/+/, "");
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileUri.startsWith("file://") && !fileUri.startsWith("content://")) return;
|
||||
addPendingFile({ uri: fileUri, filename: filenameFromUri(fileUri) });
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth guard – redirects to the correct route group after auth state resolves
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AuthGuard() {
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
const { addPendingFile } = useShare();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
// Listen for files shared from other apps (iOS Share Sheet / Android Intent).
|
||||
// Both cold-start (app was not running) and warm-start (app in background)
|
||||
// cases are handled.
|
||||
useEffect(() => {
|
||||
const handleIncomingUrl = makeUrlHandler(addPendingFile);
|
||||
|
||||
// Cold start – app launched directly by a share action
|
||||
Linking.getInitialURL().then((url) => {
|
||||
if (url) handleIncomingUrl({ url });
|
||||
});
|
||||
|
||||
// Warm start – app was already running when the share action occurred
|
||||
const subscription = Linking.addEventListener("url", handleIncomingUrl);
|
||||
return () => subscription.remove();
|
||||
}, [addPendingFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
const inAuthGroup = segments[0] === "(auth)";
|
||||
|
||||
if (!isAuthenticated && !inAuthGroup) {
|
||||
// Unauthenticated visitor outside the auth group → send to welcome
|
||||
router.replace("/(auth)/");
|
||||
} else if (isAuthenticated && inAuthGroup) {
|
||||
// Authenticated user on auth screens → send to main app
|
||||
router.replace("/(tabs)/");
|
||||
}
|
||||
}, [isAuthenticated, isLoading, segments, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="large" color="#1e40af" />
|
||||
<Text style={styles.loadingText}>Loading…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root layout export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<ShareProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard />
|
||||
</AuthProvider>
|
||||
</ShareProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
color: "#6b7280",
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 746 B |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 5.9.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"node": "20.19.4",
|
||||
"env": {
|
||||
"NODE_NO_WARNINGS": "1"
|
||||
},
|
||||
"ios": {
|
||||
"image": "macos-sequoia-15.5-xcode-26.0"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"node": "20.19.4",
|
||||
"env": {
|
||||
"NODE_NO_WARNINGS": "1"
|
||||
},
|
||||
"ios": {
|
||||
"simulator": false,
|
||||
"image": "macos-sequoia-15.5-xcode-26.0"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"node": "20.19.4",
|
||||
"env": {
|
||||
"NODE_NO_WARNINGS": "1"
|
||||
},
|
||||
"ios": {
|
||||
"image": "macos-sequoia-15.5-xcode-26.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "christianlouis@gmail.com",
|
||||
"ascAppId": "6760611200",
|
||||
"appleTeamId": "975U2ZESBM"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./google-play-service-account.json",
|
||||
"track": "production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+13431
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "docuelevate-mobile",
|
||||
"version": "1.0.0",
|
||||
"description": "DocuElevate native mobile app (iOS and Android)",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"type-check": "tsc --noEmit",
|
||||
"build:ios": "eas build --platform ios",
|
||||
"build:android": "eas build --platform android",
|
||||
"build:all": "eas build --platform all",
|
||||
"submit:ios": "eas submit --platform ios",
|
||||
"submit:android": "eas submit --platform android"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@react-navigation/native-stack": "^7.2.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-font": "~14.0.0",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-safe-area-context": "5.6.0",
|
||||
"react-native-screens": "4.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.4"
|
||||
},
|
||||
"private": true,
|
||||
"expo": {
|
||||
"doctor": {
|
||||
"reactNativeDirectoryCheck": {
|
||||
"exclude": ["@react-navigation/bottom-tabs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Authentication context for the DocuElevate mobile app.
|
||||
*
|
||||
* Manages the lifecycle of the stored API token and user profile. The SSO
|
||||
* login flow uses expo-auth-session to open the server's OAuth page in the
|
||||
* system browser; on return the redirect URL carries a one-time code that is
|
||||
* exchanged for a session cookie, which is then traded for a permanent API
|
||||
* token via POST /api/mobile/generate-token.
|
||||
*/
|
||||
|
||||
import * as Linking from "expo-linking";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
SECURE_STORE_API_TOKEN_KEY,
|
||||
SECURE_STORE_BASE_URL_KEY,
|
||||
SECURE_STORE_OWNER_ID_KEY,
|
||||
api,
|
||||
type WhoAmIResponse,
|
||||
} from "../services/api";
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuthState {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: WhoAmIResponse | null;
|
||||
baseUrl: string;
|
||||
signIn: (serverUrl: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setToken: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AuthContext = createContext<AuthState>({
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
baseUrl: "",
|
||||
signIn: async () => {},
|
||||
signOut: async () => {},
|
||||
setToken: async () => {},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState<WhoAmIResponse | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
|
||||
// On mount: restore persisted session
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const storedUrl = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
|
||||
const storedToken = await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
|
||||
if (storedUrl && storedToken) {
|
||||
await api.init(storedUrl);
|
||||
setBaseUrl(storedUrl);
|
||||
// Verify token is still valid
|
||||
const profile = await api.whoAmI();
|
||||
setUser(profile);
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch {
|
||||
// Token expired or server unavailable – clear stored credentials
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const setToken = useCallback(async (token: string) => {
|
||||
await SecureStore.setItemAsync(SECURE_STORE_API_TOKEN_KEY, token);
|
||||
const profile = await api.whoAmI();
|
||||
setUser(profile);
|
||||
await SecureStore.setItemAsync(SECURE_STORE_OWNER_ID_KEY, profile.owner_id);
|
||||
setIsAuthenticated(true);
|
||||
}, []);
|
||||
|
||||
const signIn = useCallback(
|
||||
async (serverUrl: string) => {
|
||||
const cleanUrl = serverUrl.replace(/\/$/, "");
|
||||
await api.init(cleanUrl);
|
||||
setBaseUrl(cleanUrl);
|
||||
|
||||
// Compute the correct redirect URI for the current runtime:
|
||||
// • Expo Go (development) → exp://<host>:<port>/--/callback
|
||||
// • Standalone / EAS build → docuelevate://callback
|
||||
// Both schemes are accepted by the server; using Linking.createURL
|
||||
// ensures the browser deep-link resolves correctly in every environment.
|
||||
const redirectUri = Linking.createURL("callback");
|
||||
|
||||
// Open the web login page in the system browser. The user authenticates
|
||||
// via SSO or local credentials, then the app deep-link is triggered.
|
||||
// The WebBrowser.openAuthSessionAsync handles the redirect back to the app.
|
||||
const result = await WebBrowser.openAuthSessionAsync(
|
||||
`${cleanUrl}/login?mobile=1&redirect_uri=${encodeURIComponent(redirectUri)}`,
|
||||
redirectUri
|
||||
);
|
||||
|
||||
if (result.type !== "success") {
|
||||
throw new Error("Authentication was cancelled or failed");
|
||||
}
|
||||
|
||||
// Parse the token from the redirect URL if the server appended it,
|
||||
// otherwise hit the generate-token endpoint (session cookie is carried
|
||||
// by the WebBrowser).
|
||||
const url = new URL(result.url);
|
||||
const inlineToken = url.searchParams.get("token");
|
||||
|
||||
if (inlineToken) {
|
||||
await setToken(inlineToken);
|
||||
} else {
|
||||
// The server set a session cookie during the browser session; exchange
|
||||
// it for a persistent API token.
|
||||
const deviceInfo = await _getDeviceName();
|
||||
const tokenResp = await api.generateMobileToken(deviceInfo);
|
||||
await setToken(tokenResp.token);
|
||||
}
|
||||
},
|
||||
[setToken]
|
||||
);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
user,
|
||||
baseUrl,
|
||||
signIn,
|
||||
signOut,
|
||||
setToken,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function _getDeviceName(): Promise<string> {
|
||||
try {
|
||||
const Constants = await import("expo-constants");
|
||||
return (
|
||||
Constants.default.deviceName ||
|
||||
Constants.default.expoConfig?.name ||
|
||||
"Mobile App"
|
||||
);
|
||||
} catch {
|
||||
return "Mobile App";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* ShareContext – propagates files received from the iOS Share Sheet or the
|
||||
* Android Share Intent to the UploadScreen so they can be uploaded
|
||||
* automatically.
|
||||
*
|
||||
* The root layout listens for incoming file:// / content:// URLs via
|
||||
* expo-linking and calls addPendingFile(). UploadScreen consumes the context,
|
||||
* uploads each pending file, then calls clearPendingFiles().
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
export interface SharedFile {
|
||||
uri: string;
|
||||
filename: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
interface ShareContextValue {
|
||||
pendingFiles: SharedFile[];
|
||||
addPendingFile: (file: SharedFile) => void;
|
||||
clearPendingFiles: () => void;
|
||||
}
|
||||
|
||||
const ShareContext = createContext<ShareContextValue>({
|
||||
pendingFiles: [],
|
||||
addPendingFile: () => {},
|
||||
clearPendingFiles: () => {},
|
||||
});
|
||||
|
||||
export function ShareProvider({ children }: { children: React.ReactNode }) {
|
||||
const [pendingFiles, setPendingFiles] = useState<SharedFile[]>([]);
|
||||
|
||||
const addPendingFile = useCallback((file: SharedFile) => {
|
||||
setPendingFiles((prev) => [...prev, file]);
|
||||
}, []);
|
||||
|
||||
const clearPendingFiles = useCallback(() => {
|
||||
setPendingFiles([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ShareContext.Provider value={{ pendingFiles, addPendingFile, clearPendingFiles }}>
|
||||
{children}
|
||||
</ShareContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useShare(): ShareContextValue {
|
||||
return useContext(ShareContext);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* usePushNotifications – register the device for push notifications.
|
||||
*
|
||||
* Requests the user's permission for notifications, obtains an Expo push
|
||||
* token, and registers it with the DocuElevate backend via
|
||||
* POST /api/mobile/register-device.
|
||||
*
|
||||
* This hook should be called once from the root component after the user has
|
||||
* successfully authenticated.
|
||||
*/
|
||||
|
||||
import Constants from "expo-constants";
|
||||
import * as Device from "expo-device";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import api from "../services/api";
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export function usePushNotifications(isAuthenticated: boolean) {
|
||||
const notificationListener = useRef<Notifications.Subscription | null>(null);
|
||||
const responseListener = useRef<Notifications.Subscription | null>(null);
|
||||
|
||||
const registerForPushNotifications = useCallback(async () => {
|
||||
if (!Device.isDevice) {
|
||||
// Push tokens are not available in simulators.
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
await Notifications.setNotificationChannelAsync("default", {
|
||||
name: "DocuElevate",
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: "#1e40af",
|
||||
});
|
||||
}
|
||||
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
|
||||
if (existingStatus !== "granted") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
}
|
||||
|
||||
if (finalStatus !== "granted") {
|
||||
// User declined – no push notifications
|
||||
return;
|
||||
}
|
||||
|
||||
let projectId: string | undefined;
|
||||
try {
|
||||
projectId =
|
||||
Constants.expoConfig?.extra?.eas?.projectId ??
|
||||
Constants.easConfig?.projectId;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const tokenData = await Notifications.getExpoPushTokenAsync(
|
||||
projectId ? { projectId } : undefined
|
||||
);
|
||||
|
||||
const pushToken = tokenData.data;
|
||||
const platform = Platform.OS as "ios" | "android" | "web";
|
||||
|
||||
let deviceName = "Mobile App";
|
||||
try {
|
||||
deviceName = Device.modelName ?? Device.deviceName ?? "Mobile App";
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
await api.registerDevice({ push_token: pushToken, device_name: deviceName, platform });
|
||||
} catch {
|
||||
// Registration failure is non-fatal – the app still works without push.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
registerForPushNotifications();
|
||||
|
||||
// Listen for incoming notifications while app is foregrounded
|
||||
notificationListener.current = Notifications.addNotificationReceivedListener((notification) => {
|
||||
console.log("Notification received:", notification.request.content.title);
|
||||
});
|
||||
|
||||
// Listen for user taps on notifications
|
||||
responseListener.current = Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
const data = response.notification.request.content.data as Record<string, unknown>;
|
||||
// Navigate to file detail if file_id is present
|
||||
if (data?.file_id) {
|
||||
console.log("User tapped notification for file:", data.file_id);
|
||||
// Navigation would be wired up by the caller via a callback prop
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
notificationListener.current?.remove();
|
||||
responseListener.current?.remove();
|
||||
};
|
||||
}, [isAuthenticated, registerForPushNotifications]);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* FilesScreen – list of documents processed by DocuElevate.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import type { FileRecord } from "../services/api";
|
||||
import api from "../services/api";
|
||||
|
||||
function formatBytes(bytes: number | null): 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 formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function statusEmoji(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
completed: "✅",
|
||||
processing: "⚙️",
|
||||
pending: "⏳",
|
||||
failed: "❌",
|
||||
duplicate: "🔁",
|
||||
};
|
||||
return map[status?.toLowerCase()] ?? "📄";
|
||||
}
|
||||
|
||||
export default function FilesScreen() {
|
||||
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 fetchFiles = useCallback(
|
||||
async (pageNum: number, replace: boolean) => {
|
||||
try {
|
||||
const data = await api.listFiles(pageNum, 20);
|
||||
if (replace) {
|
||||
setFiles(data);
|
||||
} else {
|
||||
setFiles((prev) => [...prev, ...data]);
|
||||
}
|
||||
setHasMore(data.length === 20);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load files");
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
await fetchFiles(1, true);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [fetchFiles]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
setPage(1);
|
||||
await fetchFiles(1, true);
|
||||
setRefreshing(false);
|
||||
}, [fetchFiles]);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!hasMore || loading || refreshing) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
await fetchFiles(next, false);
|
||||
}, [fetchFiles, hasMore, loading, page, refreshing]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color="#1e40af" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={handleRefresh}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
<Text style={styles.emptyEmoji}>📂</Text>
|
||||
<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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ file }: { file: FileRecord }) {
|
||||
const status = file.processing_status?.status ?? "pending";
|
||||
return (
|
||||
<View style={rowStyles.row}>
|
||||
<Text style={rowStyles.icon}>{statusEmoji(status)}</Text>
|
||||
<View style={rowStyles.info}>
|
||||
<Text style={rowStyles.filename} numberOfLines={1}>
|
||||
{file.original_filename}
|
||||
</Text>
|
||||
<Text style={rowStyles.meta}>
|
||||
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={rowStyles.status}>{status}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
listContent: { padding: 16 },
|
||||
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,
|
||||
},
|
||||
retryText: { color: "#fff", fontWeight: "600" },
|
||||
emptyState: { alignItems: "center", paddingTop: 60 },
|
||||
emptyEmoji: { fontSize: 48, marginBottom: 12 },
|
||||
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
|
||||
emptyHint: {
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
});
|
||||
|
||||
const rowStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.04,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
icon: { fontSize: 22, marginRight: 12 },
|
||||
info: { flex: 1 },
|
||||
filename: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
meta: { fontSize: 12, color: "#6b7280" },
|
||||
status: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
fontWeight: "500",
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* LoginScreen – server URL entry and SSO sign-in.
|
||||
*
|
||||
* Renders a server URL input and a "Sign in with SSO" button that opens the
|
||||
* DocuElevate web login page in the system browser. On success the
|
||||
* AuthContext stores the API token and navigates to the main app.
|
||||
*/
|
||||
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { signIn } = useAuth();
|
||||
const router = useRouter();
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSignIn() {
|
||||
const url = serverUrl.trim();
|
||||
if (!url) {
|
||||
Alert.alert("Server URL required", "Please enter the URL of your DocuElevate server.");
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
Alert.alert("Invalid URL", "The server URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn(url);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Sign-in failed";
|
||||
Alert.alert("Sign-in failed", message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={require("../../assets/logo.png")}
|
||||
style={styles.logoImage}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel="DocuElevate logo"
|
||||
/>
|
||||
<Text style={styles.logoText}>DocuElevate</Text>
|
||||
</View>
|
||||
<Text style={styles.tagline}>Intelligent Document Processing</Text>
|
||||
|
||||
<Text style={styles.label}>Server URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://your-docuelevate-server.com"
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrl}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={handleSignIn}
|
||||
accessibilityLabel="Server URL"
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleSignIn}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign in with SSO"
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign in with SSO</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
You will be redirected to your organisation's sign-in page.
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back to welcome screen"
|
||||
style={styles.backLink}
|
||||
>
|
||||
<Text style={styles.backLinkText}>← Back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f3f4f6",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: 16,
|
||||
padding: 28,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.08,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: "center",
|
||||
marginBottom: 4,
|
||||
},
|
||||
logoImage: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
marginBottom: 10,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 28,
|
||||
fontWeight: "700",
|
||||
color: "#1e40af",
|
||||
textAlign: "center",
|
||||
},
|
||||
tagline: {
|
||||
fontSize: 14,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
marginBottom: 32,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#374151",
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 15,
|
||||
color: "#111827",
|
||||
marginBottom: 20,
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#1e40af",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 48,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
hint: {
|
||||
marginTop: 16,
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
textAlign: "center",
|
||||
},
|
||||
backLink: {
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
minHeight: 44,
|
||||
justifyContent: "center",
|
||||
},
|
||||
backLinkText: {
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* ProfileScreen – authenticated user profile and settings.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, signOut, baseUrl } = useAuth();
|
||||
|
||||
function handleSignOut() {
|
||||
Alert.alert("Sign out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign out",
|
||||
style: "destructive",
|
||||
onPress: signOut,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.emptyText}>Not signed in</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
{/* Avatar + name */}
|
||||
<View style={styles.profileCard}>
|
||||
{user.avatar_url ? (
|
||||
<Image
|
||||
source={{ uri: user.avatar_url }}
|
||||
style={styles.avatar}
|
||||
accessibilityLabel={`Avatar for ${user.display_name ?? user.owner_id}`}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarInitial}>
|
||||
{(user.display_name ?? user.owner_id).charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text>
|
||||
{user.email && <Text style={styles.email}>{user.email}</Text>}
|
||||
{user.is_admin && <Text style={styles.adminBadge}>Admin</Text>}
|
||||
</View>
|
||||
|
||||
{/* Server info */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Connection</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Server</Text>
|
||||
<Text style={styles.rowValue} numberOfLines={1}>
|
||||
{baseUrl || "–"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>User ID</Text>
|
||||
<Text style={styles.rowValue} numberOfLines={1}>
|
||||
{user.owner_id}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Danger zone */}
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={styles.signOutButton}
|
||||
onPress={handleSignOut}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign out"
|
||||
>
|
||||
<Text style={styles.signOutText}>Sign out</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
content: { padding: 20 },
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
emptyText: { color: "#6b7280", fontSize: 16 },
|
||||
profileCard: {
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
marginBottom: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.06,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
marginBottom: 14,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
backgroundColor: "#1e40af",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
avatarInitial: {
|
||||
color: "#fff",
|
||||
fontSize: 32,
|
||||
fontWeight: "700",
|
||||
},
|
||||
displayName: {
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
email: { fontSize: 14, color: "#6b7280", marginBottom: 6 },
|
||||
adminBadge: {
|
||||
backgroundColor: "#dbeafe",
|
||||
color: "#1e40af",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.04,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 12,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f3f4f6",
|
||||
},
|
||||
rowLabel: { fontSize: 14, color: "#374151" },
|
||||
rowValue: {
|
||||
fontSize: 14,
|
||||
color: "#6b7280",
|
||||
maxWidth: "60%",
|
||||
textAlign: "right",
|
||||
},
|
||||
signOutButton: {
|
||||
backgroundColor: "#fee2e2",
|
||||
borderRadius: 10,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
minHeight: 48,
|
||||
},
|
||||
signOutText: {
|
||||
color: "#dc2626",
|
||||
fontWeight: "700",
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* UploadScreen – document upload via camera, photo library, or file picker.
|
||||
*
|
||||
* Users can:
|
||||
* 1. Take a photo of a document with the device camera.
|
||||
* 2. Select an existing photo from the device's photo library.
|
||||
* 3. Pick an existing file (PDF, image, Office document) from the Files app.
|
||||
* 4. Receive files shared from other apps via the iOS Share Sheet / Android
|
||||
* Share Intent (handled via ShareContext populated by the root layout).
|
||||
*
|
||||
* After a successful upload the screen polls the backend every 5 seconds to
|
||||
* track the real-time processing status of each uploaded file.
|
||||
*/
|
||||
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useShare } from "../context/ShareContext";
|
||||
import api from "../services/api";
|
||||
|
||||
/** Statuses that indicate processing has finished (no further polling needed). */
|
||||
const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]);
|
||||
|
||||
interface UploadItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
status: "pending" | "uploading" | "done" | "error";
|
||||
error?: string;
|
||||
taskId?: string;
|
||||
/** Sanitised filename returned by the server – used to search for the record. */
|
||||
originalFilename?: string;
|
||||
/** Database ID of the FileRecord once it has been created by the worker. */
|
||||
fileId?: number;
|
||||
/** Actual server-side processing status (e.g. "pending", "processing", "completed"). */
|
||||
serverStatus?: string;
|
||||
/** Original file URI – retained so the upload can be retried on failure. */
|
||||
uri?: string;
|
||||
/** MIME type of the original file. */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export default function UploadScreen() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { pendingFiles, clearPendingFiles } = useShare();
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||
|
||||
// Keep a ref in sync so the polling interval can read current state without
|
||||
// capturing a stale closure.
|
||||
const uploadsRef = useRef<UploadItem[]>([]);
|
||||
useEffect(() => {
|
||||
uploadsRef.current = uploads;
|
||||
}, [uploads]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core helpers (declared before the effects that depend on them)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => {
|
||||
const id = `${Date.now()}-${filename}`;
|
||||
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
|
||||
|
||||
try {
|
||||
const resp = await api.uploadFile(uri, filename, mimeType);
|
||||
setUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: item
|
||||
)
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||
setUploads((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const retryUpload = useCallback(async (item: UploadItem) => {
|
||||
if (!item.uri) return;
|
||||
|
||||
// Reset the item to "uploading" and clear previous error/server state.
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? { ...u, status: "uploading" as const, error: undefined, serverStatus: undefined, fileId: undefined, taskId: undefined, originalFilename: undefined }
|
||||
: u
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const resp = await api.uploadFile(item.uri, item.filename, item.mimeType);
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: u
|
||||
)
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||
setUploads((prev) =>
|
||||
prev.map((u) => (u.id === item.id ? { ...u, status: "error", error: msg } : u))
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Polling – check server-side processing status every 5 seconds
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
const current = uploadsRef.current;
|
||||
for (const item of current) {
|
||||
// Only poll items that were successfully uploaded and haven't reached a
|
||||
// terminal status yet.
|
||||
if (item.status !== "done") continue;
|
||||
if (item.serverStatus && TERMINAL_STATUSES.has(item.serverStatus)) continue;
|
||||
|
||||
try {
|
||||
if (item.fileId !== undefined) {
|
||||
// We already know the file ID – just refresh its status.
|
||||
const ps = await api.getFileStatus(item.fileId);
|
||||
setUploads((prev) =>
|
||||
prev.map((u) => (u.id === item.id ? { ...u, serverStatus: ps.status } : u))
|
||||
);
|
||||
} else if (item.originalFilename) {
|
||||
// Search for the file by name; it may not exist yet if the worker
|
||||
// hasn't started.
|
||||
const files = await api.listFiles(1, 5, item.originalFilename);
|
||||
const found = files.find((f) => f.original_filename === item.originalFilename);
|
||||
if (found) {
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? { ...u, fileId: found.id, serverStatus: found.processing_status.status }
|
||||
: u
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Network errors are transient – silently retry on the next tick.
|
||||
console.debug("[StatusPoll] failed:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(poll, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []); // Intentionally empty – poll() reads state via uploadsRef
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handle files received from the iOS Share Sheet / Android Share Intent
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (pendingFiles.length === 0 || !isAuthenticated) return;
|
||||
const files = [...pendingFiles];
|
||||
clearPendingFiles();
|
||||
files.forEach((file) => {
|
||||
void uploadFile(file.uri, file.filename, file.mimeType);
|
||||
});
|
||||
}, [pendingFiles, isAuthenticated, clearPendingFiles, uploadFile]);
|
||||
|
||||
async function handleCamera() {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert(
|
||||
"Camera access required",
|
||||
"Please grant camera access in Settings to capture documents."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ["images"],
|
||||
quality: 0.9,
|
||||
allowsEditing: false,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets.length > 0) {
|
||||
const asset = result.assets[0];
|
||||
const filename = `scan_${Date.now()}.jpg`;
|
||||
await uploadFile(asset.uri, filename, "image/jpeg");
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePhotoLibrary() {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert(
|
||||
"Photo library access required",
|
||||
"Please grant photo library access in Settings to select images."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
quality: 0.9,
|
||||
allowsEditing: false,
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilePicker() {
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: "*/*",
|
||||
multiple: true,
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
for (const asset of result.assets) {
|
||||
await uploadFile(asset.uri, asset.name, asset.mimeType ?? undefined);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
Alert.alert("File picker error", err instanceof Error ? err.message : "Could not open file picker");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.emptyText}>Please sign in to upload documents.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Action buttons */}
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.cameraButton]}
|
||||
onPress={handleCamera}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Capture document with camera"
|
||||
>
|
||||
<Text style={styles.actionIcon}>📷</Text>
|
||||
<Text style={styles.actionLabel}>Camera</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.photoLibraryButton]}
|
||||
onPress={handlePhotoLibrary}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select photo from library"
|
||||
>
|
||||
<Text style={styles.actionIcon}>🖼️</Text>
|
||||
<Text style={styles.actionLabel}>Photos</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.fileButton]}
|
||||
onPress={handleFilePicker}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Pick file from device"
|
||||
>
|
||||
<Text style={styles.actionIcon}>📄</Text>
|
||||
<Text style={styles.actionLabel}>Files</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Upload list */}
|
||||
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
|
||||
{uploads.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyEmoji}>☁️</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Tap Camera, Photos, or Files to upload a document.
|
||||
</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
You can also share files from other apps directly to DocuElevate.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
uploads.map((item) => (
|
||||
<UploadRow key={item.id} item={item} onRetry={retryUpload} />
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
|
||||
const uploadIcons: Record<UploadItem["status"], string> = {
|
||||
pending: "⏳",
|
||||
uploading: "⬆️",
|
||||
done: "✅",
|
||||
error: "❌",
|
||||
};
|
||||
|
||||
/** Human-readable label for the server-side processing status. */
|
||||
function serverStatusLabel(s: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
pending: "Queued for processing…",
|
||||
processing: "Processing…",
|
||||
completed: "Processed ✓",
|
||||
failed: "Processing failed",
|
||||
duplicate: "Duplicate – already processed",
|
||||
};
|
||||
return labels[s] ?? s;
|
||||
}
|
||||
|
||||
const canRetry = item.status === "error" && !!item.uri;
|
||||
|
||||
function handleLongPress() {
|
||||
if (!canRetry) return;
|
||||
Alert.alert("Retry Upload", `Do you want to retry uploading "${item.filename}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Retry", onPress: () => onRetry(item) },
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onLongPress={handleLongPress}
|
||||
onPress={canRetry ? () => onRetry(item) : undefined}
|
||||
style={rowStyles.row}
|
||||
accessibilityRole={canRetry ? "button" : "none"}
|
||||
accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined}
|
||||
accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined}
|
||||
>
|
||||
<Text style={rowStyles.icon}>{uploadIcons[item.status]}</Text>
|
||||
<View style={rowStyles.info}>
|
||||
<Text style={rowStyles.filename} numberOfLines={1}>
|
||||
{item.filename}
|
||||
</Text>
|
||||
{item.status === "uploading" && (
|
||||
<ActivityIndicator size="small" color="#1e40af" />
|
||||
)}
|
||||
{item.status === "done" && !item.serverStatus && (
|
||||
<Text style={rowStyles.statusQueued}>Queued for processing…</Text>
|
||||
)}
|
||||
{item.status === "done" && item.serverStatus && (
|
||||
<Text
|
||||
style={
|
||||
item.serverStatus === "completed"
|
||||
? rowStyles.statusDone
|
||||
: item.serverStatus === "failed"
|
||||
? rowStyles.statusError
|
||||
: rowStyles.statusQueued
|
||||
}
|
||||
>
|
||||
{serverStatusLabel(item.serverStatus)}
|
||||
</Text>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<View>
|
||||
<Text style={rowStyles.statusError}>{item.error}</Text>
|
||||
{canRetry && (
|
||||
<Text style={rowStyles.retryHint}>Tap to retry</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 80,
|
||||
},
|
||||
cameraButton: { backgroundColor: "#1e40af" },
|
||||
photoLibraryButton: { backgroundColor: "#7c3aed" },
|
||||
fileButton: { backgroundColor: "#059669" },
|
||||
actionIcon: { fontSize: 28, marginBottom: 6 },
|
||||
actionLabel: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
list: { flex: 1 },
|
||||
listContent: { padding: 16 },
|
||||
emptyState: {
|
||||
alignItems: "center",
|
||||
paddingTop: 60,
|
||||
},
|
||||
emptyEmoji: { fontSize: 48, marginBottom: 12 },
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: "#374151",
|
||||
textAlign: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyHint: {
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
|
||||
const rowStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.04,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
icon: { fontSize: 22, marginRight: 12 },
|
||||
info: { flex: 1 },
|
||||
filename: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusDone: { fontSize: 12, color: "#059669" },
|
||||
statusQueued: { fontSize: 12, color: "#6b7280" },
|
||||
statusError: { fontSize: 12, color: "#dc2626" },
|
||||
retryHint: { fontSize: 12, color: "#1e40af", fontWeight: "600", marginTop: 4 },
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* WelcomeScreen – first screen shown to unauthenticated users on first launch.
|
||||
*
|
||||
* Presents the DocuElevate brand, a short description of what the app does,
|
||||
* and a "Get Started" button that navigates to the LoginScreen.
|
||||
*/
|
||||
|
||||
import { useRouter } from "expo-router";
|
||||
import React from "react";
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
const FEATURES: { icon: string; title: string; description: string }[] = [
|
||||
{
|
||||
icon: "🔍",
|
||||
title: "OCR & Text Extraction",
|
||||
description: "Convert scanned PDFs and images into fully searchable text automatically.",
|
||||
},
|
||||
{
|
||||
icon: "🤖",
|
||||
title: "AI Metadata Extraction",
|
||||
description: "AI classifies documents and pulls out key fields like dates, amounts, and subjects.",
|
||||
},
|
||||
{
|
||||
icon: "☁️",
|
||||
title: "Multi-Cloud Storage",
|
||||
description: "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Hero */}
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={require("../../assets/logo.png")}
|
||||
style={styles.logoImage}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel="DocuElevate logo"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.appName}>DocuElevate</Text>
|
||||
<Text style={styles.tagline}>Intelligent Document Processing</Text>
|
||||
<Text style={styles.heroDescription}>
|
||||
Ingest documents, run OCR, extract metadata with AI, and route files
|
||||
to your cloud storage — all in one seamless pipeline.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Feature highlights */}
|
||||
<View style={styles.features}>
|
||||
{FEATURES.map((feature) => (
|
||||
<View key={feature.title} style={styles.featureRow}>
|
||||
<Text style={styles.featureIcon} aria-hidden={true}>{feature.icon}</Text>
|
||||
<View style={styles.featureText}>
|
||||
<Text style={styles.featureTitle}>{feature.title}</Text>
|
||||
<Text style={styles.featureDescription}>{feature.description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* CTA */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
|
||||
onPress={() => router.push("/(auth)/login")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Get started — connect to your DocuElevate server"
|
||||
>
|
||||
<Text style={styles.buttonText}>Get Started</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
Connect to your self-hosted or cloud DocuElevate server.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: {
|
||||
flex: 1,
|
||||
backgroundColor: "#1e40af",
|
||||
},
|
||||
scroll: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 28,
|
||||
paddingTop: 48,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
hero: {
|
||||
alignItems: "center",
|
||||
marginBottom: 40,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 24,
|
||||
backgroundColor: "rgba(255,255,255,0.15)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
logoImage: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
},
|
||||
appName: {
|
||||
fontSize: 36,
|
||||
fontWeight: "800",
|
||||
color: "#ffffff",
|
||||
textAlign: "center",
|
||||
marginBottom: 6,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
tagline: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "rgba(255,255,255,0.75)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1.5,
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
heroDescription: {
|
||||
fontSize: 16,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
textAlign: "center",
|
||||
lineHeight: 24,
|
||||
maxWidth: 320,
|
||||
},
|
||||
features: {
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderRadius: 16,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 36,
|
||||
},
|
||||
featureRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: "rgba(255,255,255,0.2)",
|
||||
},
|
||||
featureIcon: {
|
||||
fontSize: 24,
|
||||
marginRight: 14,
|
||||
marginTop: 1,
|
||||
},
|
||||
featureText: {
|
||||
flex: 1,
|
||||
},
|
||||
featureTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#ffffff",
|
||||
marginBottom: 2,
|
||||
},
|
||||
featureDescription: {
|
||||
fontSize: 13,
|
||||
color: "rgba(255,255,255,0.75)",
|
||||
lineHeight: 18,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 52,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.15,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.9,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
buttonText: {
|
||||
color: "#1e40af",
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.55)",
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* DocuElevate API client for the mobile app.
|
||||
*
|
||||
* All requests authenticate via a Bearer token stored in the device's secure
|
||||
* keychain (via expo-secure-store). The token is obtained once through the
|
||||
* SSO flow and cached until the user explicitly logs out.
|
||||
*/
|
||||
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SECURE_STORE_API_TOKEN_KEY = "de_api_token";
|
||||
export const SECURE_STORE_BASE_URL_KEY = "de_base_url";
|
||||
export const SECURE_STORE_OWNER_ID_KEY = "de_owner_id";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WhoAmIResponse {
|
||||
owner_id: string;
|
||||
display_name: string | null;
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface GenerateTokenResponse {
|
||||
token: string;
|
||||
token_id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DeviceRegistration {
|
||||
push_token: string;
|
||||
device_name?: string;
|
||||
platform: "ios" | "android" | "web";
|
||||
}
|
||||
|
||||
export interface ProcessingStatus {
|
||||
status: string;
|
||||
last_step: string | null;
|
||||
has_errors: boolean;
|
||||
total_steps: number;
|
||||
}
|
||||
|
||||
export interface FileRecord {
|
||||
id: number;
|
||||
original_filename: string;
|
||||
file_size: number | null;
|
||||
mime_type: string | null;
|
||||
created_at: string;
|
||||
processing_status: ProcessingStatus;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
task_id: string;
|
||||
status: string;
|
||||
original_filename: string;
|
||||
stored_filename: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base API client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class DocuElevateAPI {
|
||||
private baseUrl: string = "";
|
||||
|
||||
async init(baseUrl: string): Promise<void> {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
await SecureStore.setItemAsync(SECURE_STORE_BASE_URL_KEY, this.baseUrl);
|
||||
}
|
||||
|
||||
async loadFromStorage(): Promise<boolean> {
|
||||
try {
|
||||
const url = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
|
||||
if (url) {
|
||||
this.baseUrl = url;
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { body?: unknown; formData?: FormData }
|
||||
): Promise<T> {
|
||||
const token = await this.getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let body: string | FormData | undefined;
|
||||
if (options?.formData) {
|
||||
body = options.formData;
|
||||
// Let fetch set multipart content-type with boundary automatically
|
||||
} else if (options?.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = `HTTP ${response.status}`;
|
||||
try {
|
||||
const err = await response.json();
|
||||
detail = err.detail || JSON.stringify(err);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Exchange the current session (cookie) for a long-lived API token. */
|
||||
async generateMobileToken(deviceName: string): Promise<GenerateTokenResponse> {
|
||||
return this.request<GenerateTokenResponse>("POST", "/api/mobile/generate-token", {
|
||||
body: { device_name: deviceName },
|
||||
});
|
||||
}
|
||||
|
||||
/** Return profile information for the authenticated user. */
|
||||
async whoAmI(): Promise<WhoAmIResponse> {
|
||||
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Push notifications
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Register a push notification device token. */
|
||||
async registerDevice(data: DeviceRegistration): Promise<void> {
|
||||
await this.request("POST", "/api/mobile/register-device", { body: data });
|
||||
}
|
||||
|
||||
/** Deactivate a device registration. */
|
||||
async deactivateDevice(deviceId: number): Promise<void> {
|
||||
await this.request("DELETE", `/api/mobile/devices/${deviceId}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Files
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Upload a file for processing. */
|
||||
async uploadFile(uri: string, filename: string, mimeType?: string): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", {
|
||||
uri,
|
||||
name: filename,
|
||||
type: mimeType || "application/octet-stream",
|
||||
} as unknown as Blob);
|
||||
|
||||
return this.request<UploadResponse>("POST", "/api/ui-upload", { formData });
|
||||
}
|
||||
|
||||
/** List recently processed files, optionally filtered by filename search. */
|
||||
async listFiles(page = 1, pageSize = 20, search?: string): Promise<FileRecord[]> {
|
||||
let url = `/api/files?page=${page}&per_page=${pageSize}`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
const data = await this.request<{ files: FileRecord[]; pagination: unknown }>("GET", url);
|
||||
return data.files;
|
||||
}
|
||||
|
||||
/** Get the processing status for a single file by its ID. */
|
||||
async getFileStatus(fileId: number): Promise<ProcessingStatus> {
|
||||
const data = await this.request<{ processing_status: ProcessingStatus }>(
|
||||
"GET",
|
||||
`/api/files/${fileId}`
|
||||
);
|
||||
return data.processing_status;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new DocuElevateAPI();
|
||||
export default api;
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-native",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
Reference in New Issue
Block a user