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:
copilot-swe-agent[bot]
2026-03-16 22:40:15 +00:00
parent 6a83d51d88
commit 204000aabc
318 changed files with 317516 additions and 2858 deletions
+38
View File
@@ -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 }}
+21
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
20.19.4
+9
View File
@@ -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.
*/
+237
View File
@@ -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
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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>
);
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Welcome screen route first screen shown to unauthenticated users.
*/
export { default } from "../../src/screens/WelcomeScreen";
+4
View File
@@ -0,0 +1,4 @@
/**
* Login screen route server URL entry and SSO sign-in.
*/
export { default } from "../../src/screens/LoginScreen";
+72
View File
@@ -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>
);
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Files tab route.
*/
export { default } from "../../src/screens/FilesScreen";
+4
View File
@@ -0,0 +1,4 @@
/**
* Upload tab route (default tab for authenticated users).
*/
export { default } from "../../src/screens/UploadScreen";
+4
View File
@@ -0,0 +1,4 @@
/**
* Profile tab route.
*/
export { default } from "../../src/screens/ProfileScreen";
+155
View File
@@ -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

+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};
+53
View File
@@ -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"
}
}
}
}
+13431
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -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"]
}
}
}
}
+193
View File
@@ -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";
}
}
+51
View File
@@ -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);
}
+116
View File
@@ -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]);
}
+221
View File
@@ -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",
},
});
+203
View File
@@ -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",
},
});
+195
View File
@@ -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,
},
});
+458
View File
@@ -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 },
});
+209
View File
@@ -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",
},
});
+214
View File
@@ -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;
+28
View File
@@ -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"
}