Перейти до змісту

ESWF Mobile — Driver App + Sales App

ESWF має два мобільні застосунки на одному стеку (Expo SDK 51 + React Native + WatermelonDB):

App Локація Призначення Backend API
Driver App c:\eswf\mobile\ АРМ водія: рейси, путні листи, одометр, паливо mobile_api/ (/api/v1/mobile/)
Sales App c:\eswf\mobile-sales\ АРМ торгового представника: візити, рахунки, GPS, фото, підпис sales_mobile_api/ (/api/v1/sales-mobile/)

Цей документ — детальний посібник по Driver App. Для Sales App див. розділ §13. Sales App — короткий огляд у кінці. Повна довідка по Sales App — у CLAUDE.md (секція «Mobile Sales App»).


Driver App — повна документація

Локація: c:\eswf\mobile\ Платформа: iOS / Android (React Native + Expo) Дата імплементації: 2026-02-18 Статус: ✅ Реалізовано (потребує expo prebuild для запуску)


Зміст

  1. Навіщо цей застосунок
  2. Технологічний стек — вибір і обґрунтування
  3. Архітектура офлайн-sync
  4. Структура файлів — що і навіщо
  5. Backend: Mobile API
  6. Встановлення для розробки
  7. Встановлення на мобільний телефон
  8. Як користуватися застосунком
  9. Sync: як це працює детально
  10. Конфігурація — де змінювати URL backend
  11. Production збірка (EAS Build)
  12. Що ще можна додати

1. Навіщо цей застосунок

Водій вантажівки не завжди має доступ до інтернету. Але йому потрібно:

  • бачити свої маршрути та замовлення навіть без зв'язку
  • вводити показники одометра в кінці рейсу
  • фіксувати витрати пального по дорозі
  • дивитися деталі вантажу і дату доставки

Веб-застосунок DOP (erp.eswf.dev) вирішує це для диспетчерів, але не підходить водію — він потребує нативного мобільного застосунку з офлайн-першою логікою.

Сценарій використання:

Вранці (є Wi-Fi/4G):
  → Застосунок синхронізується з сервером
  → Завантажує путні листи і замовлення на сьогодні

Вдень (немає інтернету):
  → Водій відкриває список своїх рейсів — все є в кеші
  → Вводить показники одометра в кінці маршруту
  → Дані зберігаються локально на телефоні

Ввечері (знову Wi-Fi):
  → Натискає "Sync" — дані відправляються на сервер
  → Диспетчер бачить актуальні показники в DOP


2. Технологічний стек

Чому React Native + Expo?

Альтернатива Чому не обрали
Flutter Інша мова (Dart), немає переиспользования JS коду з web-частини
Native iOS/Android Дві окремі кодові бази — вдвічі більше роботи
Capacitor/Ionic Web-view з обгорткою — гірша продуктивність, не нативний feel
React Native + Expo ✅ Один код для iOS+Android, React (знайомий), нативна продуктивність

Expo — toolchain поверх React Native, що дає: - expo prebuild — генерація native проектів без Xcode/Android Studio для основних налаштувань - EAS Build — хмарна збірка .apk/.ipa без локального SDK - Expo Go — швидке тестування (але не підходить для WatermelonDB) - expo-secure-store — захищене зберігання токенів - Автоматичне підписання та OTA-оновлення

Чому WatermelonDB?

Альтернатива Чому не обрали
AsyncStorage Не реляційна, немає реактивних запитів
expo-sqlite напряму Потрібно вручну писати SQL, немає ORM, немає sync-протоколу
Realm Комерційна ліцензія, складна схема
WatermelonDB ✅ Безкоштовна, SQLite під капотом, реактивна (RxJS), є sync-протокол

WatermelonDB дає: - Lazy loading — не завантажує всі дані одразу - Reactive queries — компоненти автоматично перерендеряться при змінах в БД - Sync protocol — стандартизований pull/push формат між клієнтом і сервером - JSI — прямий зв'язок з native SQLite через JavaScript Interface (без bridge) — дуже швидко - Optimistic updates — зміни відразу видно в UI, навіть до sync

Чому expo-secure-store для токенів?

AsyncStorage зберігає дані у відкритому вигляді (можна прочитати через adb). SecureStore використовує Keychain (iOS) / Keystore (Android) — зашифровані сховища ОС. JWT-токени — це credentials, їх треба захищати.


3. Архітектура офлайн-sync

┌─────────────────────────────────────────────────────────────┐
│                    Mobile App (React Native)                 │
│                                                             │
│  ┌──────────────────────────┐   ┌─────────────────────────┐ │
│  │   React UI               │   │  WatermelonDB           │ │
│  │   (screens, components)  │◄──│  (SQLite via JSI)       │ │
│  │   withObservables()      │   │                         │ │
│  │   → auto-rerender        │   │  Tables:                │ │
│  └──────────────────────────┘   │  - waybills             │ │
│                                 │  - orders               │ │
│  ┌──────────────────────────┐   │  - vehicles             │ │
│  │   useSync hook           │   │  - routes               │ │
│  │   - isSyncing            │   │                         │ │
│  │   - lastSyncAt           │   └──────────┬──────────────┘ │
│  │   - sync()               │              │                 │
│  └──────────┬───────────────┘              │ synchronize()   │
└─────────────┼────────────────────────────────────────────────┘
              │ (тільки при наявності інтернету)
┌─────────────────────────────────────────────────────────────┐
│               Django Backend                                 │
│                                                             │
│  GET  /api/v1/mobile/sync/pull?last_pulled_at=<ms>          │
│       ← { changes: { waybills: {...}, orders: {...} },      │
│            timestamp: 1703000000000 }                        │
│                                                             │
│  POST /api/v1/mobile/sync/push                              │
│       → { changes: { waybills: { updated: [...] } } }       │
└─────────────────────────────────────────────────────────────┘

Ключовий принцип: UI ніколи не звертається до API напряму. Він читає з локальної WatermelonDB. Sync — окремий процес у фоні.

Результат: - Відкрив список путніх листів → миттєво (з SQLite) - Немає інтернету → все одно працює - З'явився інтернет → натиснув sync → данні оновились


4. Структура файлів

mobile/App.tsx — Точка входу

<DatabaseProvider database={database}>   // WatermelonDB контекст
  <AuthProvider>                          // JWT стан
    <StatusBar />
    <AppNavigator />                      // Навігація
  </AuthProvider>
</DatabaseProvider>

Три провайдери вкладені в правильному порядку. DatabaseProvider дає доступ до WatermelonDB для всіх дочірніх компонентів через useDatabase() хук.


src/database/schema.ts — Схема бази даних

Визначає 4 таблиці WatermelonDB. Версія 1 — при зміні схеми треба збільшити версію і додати міграцію.

appSchema({
  version: 1,
  tables: [
    tableSchema({ name: 'waybills', columns: [...] }),
    tableSchema({ name: 'orders', columns: [...] }),
    tableSchema({ name: 'vehicles', columns: [...] }),   // reference
    tableSchema({ name: 'routes', columns: [...] }),     // reference
  ]
})

Навіщо зберігати vehicles і routes локально? Щоб у путньому листі показати назву ТЗ і маршрут без додаткових запитів до API.

Важливо про типи колонок: - type: 'string' — текст (WatermelonDB зберігає як TEXT в SQLite) - type: 'number' — числа (INTEGER або REAL в SQLite) - isOptional: true — поле може бути порожнім


src/database/index.ts — Ініціалізація БД

const adapter = new SQLiteAdapter({
  schema,
  migrations,
  dbName: 'eswf_driver',   // ім'я файлу SQLite на пристрої
  jsi: true,               // JSI = JavaScript Interface (швидший bridge)
  onSetUpError: (error) => { console.error(error); }
});

const database = new Database({
  adapter,
  modelClasses: [Waybill, Order, Vehicle, Route],
});

jsi: true — це оптимізація. JSI дозволяє JavaScript коду напряму викликати native функції без серіалізації через JSON. Результат — ~10x швидше ніж через стандартний React Native bridge.


src/database/models/ — Моделі WatermelonDB

Кожна модель — TypeScript клас з декораторами:

export default class Waybill extends Model {
  static table = 'waybills';  // ← ім'я таблиці зі схеми

  @text('number') number!: string;        // @text — строкове поле
  @field('odometer_start') odometerStart!: number;  // @field — числове
}

Декоратори WatermelonDB: - @text('column_name') — для рядкових полів - @field('column_name') — для числових і булевих полів - @date('column_name') — для дат (зберігає як timestamp) - @relation('table', 'fk_column') — для зв'язків між таблицями


src/database/migrations.ts — Міграції схеми

Зараз порожній (початкова версія 1). Коли потрібно додати нове поле:

// Приклад майбутньої міграції:
schemaMigrations({
  migrations: [
    {
      toVersion: 2,
      steps: [
        addColumns({
          table: 'waybills',
          columns: [{ name: 'notes', type: 'string', isOptional: true }]
        })
      ]
    }
  ]
})

Важливо: При зміні schema.ts (версія 1 → 2) завжди треба додавати міграцію. Інакше WatermelonDB скине всі локальні дані при апгрейді.


src/api/client.ts — HTTP клієнт

Axios + автоматичний refresh JWT токену:

Запит → 401 Unauthorized
  Черга запитів (queue)
  POST /auth/token/refresh/
  Успіх? → retry всіх запитів з нового токену
  Провал? → clearTokens() → logout

Навіщо черга? Якщо одночасно прийшло 3 запити з 401, не треба робити 3 refresh — достатньо одного. Всі інші чекають у черзі і потім повторюються з новим токеном.

URL для різних середовищ:

const BASE_URL = __DEV__
  ? 'http://10.0.2.2:8000/api/v1'   // Android emulator
  : 'https://api.eswf.dev/api/v1';  // Production

10.0.2.2 — це спеціальний alias для localhost хост-машини в Android emulator. Для iOS simulator можна використовувати localhost.


src/api/sync.ts — WatermelonDB Sync

Головна функція синхронізації:

await synchronize({
  database,

  pullChanges: async ({ lastPulledAt }) => {
    // lastPulledAt = timestamp останньої успішної sync (null якщо перший раз)
    const response = await fetch(`/mobile/sync/pull?last_pulled_at=${lastPulledAt ?? 0}`);
    const { changes, timestamp } = await response.json();
    return { changes, timestamp };
    // changes = { waybills: { created: [...], updated: [...], deleted: [...] } }
    // timestamp = поточний час сервера в мілісекундах (для наступного pull)
  },

  pushChanges: async ({ changes, lastPulledAt }) => {
    // changes = локальні зміни (те що водій ввів офлайн)
    await fetch('/mobile/sync/push', {
      method: 'POST',
      body: JSON.stringify({ changes }),
    });
  },
});

WatermelonDB сам керує lastPulledAt — зберігає після кожної успішної sync і передає наступного разу.


src/api/auth.ts — Авторизація

Три функції: - login(username, password) → POST /auth/login/{ access, refresh } - getDriverProfile() → GET /mobile/me/ → user + driver info - logout(refreshToken) → POST /auth/logout/ (ігнорує помилки)


src/auth/tokenStorage.ts — Зберігання токенів

// Зберегти
await SecureStore.setItemAsync('eswf_driver_tokens', JSON.stringify({ access, refresh }));

// Прочитати
const raw = await SecureStore.getItemAsync('eswf_driver_tokens');
return JSON.parse(raw);

// Видалити (logout)
await SecureStore.deleteItemAsync('eswf_driver_tokens');

SecureStore — це Keychain на iOS і EncryptedSharedPreferences / Keystore на Android. Дані зашифровані і прив'язані до застосунку.


src/auth/AuthContext.tsx — Стан авторизації

При старті застосунку:

1. getTokens() → є токени?
2. Так → GET /mobile/me/ → встановити profile
3. Ні  → показати LoginScreen
4. Помилка 401 → clearTokens() → LoginScreen

Надає через контекст: isLoading, isAuthenticated, profile, login(), logout(), refreshProfile().


src/navigation/AppNavigator.tsx — Навігація

AppNavigator
├── (isLoading) → Loading spinner
├── (isAuthenticated = false) → LoginScreen
└── (isAuthenticated = true) → MainTabs
    ├── DashboardTab → DashboardScreen
    ├── WaybillsTab → WaybillsStack
    │   ├── WaybillsList → WaybillsScreen
    │   └── WaybillDetail → WaybillDetailScreen
    ├── OrdersTab → OrdersStack
    │   ├── OrdersList → OrdersScreen
    │   └── OrderDetail → OrderDetailScreen
    └── ProfileTab → ProfileScreen

При logout isAuthenticated стає false → навігація автоматично перемикається на LoginScreen.


src/screens/DashboardScreen.tsx — Головний екран

Використовує withObservables для реактивних запитів до WatermelonDB:

const enhance = withObservables([], () => {
  const waybills$ = database.get('waybills')
    .query(Q.where('status', Q.oneOf(['draft', 'approved'])))
    .observeWithColumns(['status']);  // ← реактивно, оновлюється при зміні БД

  const pendingOrders$ = database.get('orders')
    .query(Q.where('status', Q.oneOf(['draft', 'approved'])))
    .observeCount();  // ← тільки кількість

  return { activeWaybill: waybills$.pipe(map(list => list[0])), pendingOrdersCount: pendingOrders$ };
});

Навіщо withObservables? Без нього треба вручну підписуватись/відписуватись від RxJS Observable і оновлювати стан. withObservables — HOC, що робить це автоматично.

Pull-to-refresh: RefreshControl з onRefresh={sync} — потягнути вниз = синхронізація.


src/screens/WaybillDetailScreen.tsx — Деталь путнього листа

Дозволяє водію вводити дані в кінці рейсу: - Одометр (кінець) — відстань рахується автоматично: odometerEnd - odometerStart - Паливо (кінець) — споживання рахується: fuelStart + fuelIssued - fuelEnd

Збереження — в локальну WatermelonDB (офлайн):

await database.write(async () => {
  await waybill.update((record) => {
    record.odometerEnd = parseInt(odometerEnd, 10);
    record.fuelEnd = parseFloat(fuelEnd);
    record.fuelConsumed = parseFloat(fuelConsumed);
    record.distance = record.odometerEnd - record.odometerStart;
  });
});

WatermelonDB позначає запис як _status: 'updated' і при наступній sync надсилає на сервер.


src/hooks/useSync.ts — Хук синхронізації

const { isSyncing, lastSyncAt, error, sync } = useSync();

Захищає від паралельних sync-запитів через syncInProgress ref. Перевіряє мережу перед sync — якщо isConnected === false, одразу повертає помилку.


src/hooks/useNetworkStatus.ts — Моніторинг мережі

Підписується на NetInfo.addEventListener і повертає { isConnected, isInternetReachable }. Використовується для: - Показу OfflineBanner ("Offline — showing cached data") - Блокування sync при відсутності мережі


src/components/OfflineBanner.tsx — Банер офлайн

Показується тільки при isConnected === false. Жовта смуга вгорі екрану — нагадування, що дані можуть бути застарілими.


src/components/StatusBadge.tsx — Бейдж статусу

Статус Колір
draft Темно-сірий
approved Синій
posted Зелений
cancelled Червоний

Відповідає кольоровій схемі DOP веб-застосунку.


src/theme.ts — Дизайн-система

Темна тема, узгоджена з DOP (Mantine dark). Всі кольори, відступи і розміри шрифтів — константи. Не використовувати StyleSheet з хардкодованими числами — завжди брати з COLORS, SPACING, FONT.


src/i18n/ — Інтернаціоналізація

Підтримує en і ua (як і DOP). За замовчуванням — ua. Перемикання в ProfileScreen → Lang button. setLanguage('en') / setLanguage('ua') — зміна застосовується миттєво для всього UI.


5. Backend: Mobile API

backend/mobile_api/views.py — Sync Views

SyncPullView — GET /api/v1/mobile/sync/pull

# Параметр last_pulled_at — мілісекунди з Unix epoch
since = datetime.fromtimestamp(int(last_pulled_at) / 1000, tz=timezone.utc)

# Знаходить Driver пов'язаний з поточним User
driver = Driver.objects.filter(user=request.user, tenant=tenant).first()

# Повертає тільки дані цього водія
waybill_qs = Waybill.objects.filter(tenant=tenant, driver=driver)

# Розбиває на created/updated/deleted
changes = _build_changes(waybill_qs, serialize_waybill, since)

Денормалізація: замість FK-посилань, сервер повертає вже готові рядки (vehicle_name, route_name) — WatermelonDB не підтримує JOIN, тому плоска структура.

Безпека: водій бачить тільки свої дані (driver=driver). Якщо driver = None (наприклад, адмін) — бачить всі дані тенанту.

SyncPushView — POST /api/v1/mobile/sync/push

Водій може оновлювати тільки обмежений набір полів:

updatable = ['odometer_end', 'fuel_end', 'fuel_consumed', 'distance',
             'date_departure', 'date_arrival', 'description']

Статус дозволено змінювати тільки в межах: draftapproved. Переведення в posted або cancelled — тільки з DOP (диспетчер).

DriverProfileView — GET /api/v1/mobile/me/

Повертає User + пов'язаний Driver запис. Якщо водій не прив'язаний до User — driver: null (застосунок все одно працює, але без інформації про ліцензію).

Прив'язка Driver до User

В Django admin або через DOP потрібно прив'язати Driver → User:

DOP → Fleet → Drivers → [водій] → User: [вибрати з списку]

Або через Django shell:

from fleet.models import Driver
from core.models import User

user = User.objects.get(username='driver1')
driver = Driver.objects.get(name='Іваненко Іван')
driver.user = user
driver.save()


6. Встановлення для розробки

Що потрібно

Інструмент Версія Навіщо
Node.js 18+ npm, expo
Git будь-яка клонування
Android Studio Flamingo+ Android Emulator
Xcode 14+ iOS Simulator (тільки macOS)
Java JDK 17 Android SDK build tools
EAS CLI latest Cloud builds

Крок 1 — Встановити Expo CLI

npm install -g @expo/cli eas-cli

Крок 2 — Встановити залежності

cd c:\eswf\mobile
npm install

Основні пакети, які встановляться: - @nozbe/watermelondb — офлайн БД - @nozbe/with-observables — реактивні компоненти - @react-navigation/* — навігація - expo-secure-store — зберігання токенів - @react-native-community/netinfo — стан мережі - i18next + react-i18next — інтернаціоналізація

Крок 3 — Prebuild (генерація native проектів)

cd c:\eswf\mobile
npx expo prebuild

Ця команда: - Генерує android/ і ios/ папки з native кодом - Налаштовує WatermelonDB native модулі (через @nozbe/watermelondb/expo-plugin) - Налаштовує expo-secure-store native binding

Важливо: після prebuild папки android/ і ios/ не слід редагувати вручну — вони генеруються Expo. При зміні app.json плагінів — повторити prebuild.

Крок 4A — Запуск на Android Emulator

# Запустити емулятор в Android Studio спочатку
npx expo run:android

або через готовий APK:

npx expo run:android --variant release

Крок 4B — Запуск на iOS Simulator (тільки macOS)

npx expo run:ios

Backend URL для емулятора

У файлах src/api/client.ts і src/api/sync.ts:

// Android emulator
const BASE_URL = __DEV__ ? 'http://10.0.2.2:8000/api/v1' : '...';
//                                 ^^^^^^^^
// 10.0.2.2 = localhost хост-машини для Android emulator

Для iOS Simulator замінити на:

const BASE_URL = __DEV__ ? 'http://localhost:8000/api/v1' : '...';

Для реального телефону (якщо бекенд на локальній машині):

const BASE_URL = __DEV__ ? 'http://192.168.1.100:8000/api/v1' : '...';
//                               ^^^^^^^^^^^^
// IP вашої машини в локальній мережі (ifconfig / ipconfig)


7. Встановлення на мобільний телефон

Є 4 способи, від найпростішого до production:


Спосіб 1 — Expo Go (ТІЛЬКИ для тестування UI, БЕЗ WatermelonDB)

⚠ Обмеження: WatermelonDB потребує native модулів, які недоступні в Expo Go. Застосунок запуститься, але при спробі відкрити БД впаде.

Для тестування UI без sync:

npx expo start
Відскануйте QR-код в Expo Go (iOS App Store / Google Play).

Використовуйте тільки для перевірки layout і навігації, не для функціоналу з БД.


Спосіб 2 — Development Build (рекомендовано для розробки)

Development Build — це кастомна версія Expo Go з вашими native модулями.

Крок 1: Налаштувати Expo account (безкоштовно)

eas login
# або: npx expo login

Крок 2: Ініціалізувати EAS проект

eas init

Крок 3: Зібрати dev build для Android

eas build --platform android --profile development

Або локально (потрібен Android Studio + Java):

npx expo run:android
# Це збирає і встановлює на підключений телефон через USB

Крок 4: Встановити APK на телефон

Після cloud build EAS надасть посилання на .apk. Відкрийте на телефоні — встановиться як звичайний APK.

Крок 5: Запустити Metro bundler

npx expo start --dev-client

Development build підключиться до Metro bundler по IP.


Спосіб 3 — APK для внутрішнього тестування (preview)

Повноцінна збірка без Google Play. Підходить для тестування з реальними данними.

# eas.json — конфіг профілів збірки (автоматично створюється eas init)

Якщо eas.json немає, створіть:

{
  "cli": { "version": ">= 12.0.0" },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal",
      "android": { "buildType": "apk" }
    },
    "production": {
      "android": { "buildType": "app-bundle" }
    }
  }
}

Збірка preview APK:

eas build --platform android --profile preview

EAS надасть QR-код і посилання для скачування APK. Надішліть водіям — вони встановлять самостійно.

На телефоні потрібно увімкнути:

Налаштування → Безпека → Невідомі джерела → Дозволити
# або:
Налаштування → Програми → Особливий доступ → Встановлення невідомих застосунків


Спосіб 4 — Локальна збірка без EAS (без хмари)

Якщо не хочете EAS аккаунт:

Android (потрібен Android Studio):

cd c:\eswf\mobile
npx expo prebuild --clean

cd android
./gradlew assembleRelease
# APK буде тут: android/app/build/outputs/apk/release/app-release.apk

Перенесіть APK на телефон (USB або будь-яким способом) → встановіть.

Підключення реального телефону через USB:

# Увімкніть USB debugging в телефоні:
# Налаштування → Про телефон → натисніть "Номер збірки" 7 разів
# → Для розробників → USB-відлагодження → Увімкнути

# Перевірте що телефон видно:
adb devices

# Запустіть на телефоні:
npx expo run:android --device


Спосіб 5 — Google Play / App Store (production)

# Android (App Bundle для Google Play)
eas build --platform android --profile production

# iOS (IPA для App Store, потрібен Apple Developer аккаунт $99/рік)
eas build --platform ios --profile production

# Відправка в магазини
eas submit --platform android
eas submit --platform ios

8. Як користуватися

Перший запуск

  1. Встановити застосунок (будь-яким способом вище)
  2. Відкрити — показується LoginScreen
  3. Ввести username і password від ESWF DOP акаунту
  4. Натиснути "Увійти" / "Sign In"

Вимоги до аккаунту: - Аккаунт повинен існувати в DOP (Django User) - До аккаунту повинен бути прив'язаний Driver (Fleet → Drivers) - Якщо Driver не прив'язаний — вхід спрацює, але застосунок не знайде путні листи

Dashboard (Головний екран)

  • Вітання + статус синхронізації (час останньої sync або помилка)
  • Активний путній лист — найсвіжіший путній лист зі статусом draft або approved
  • Кількість активних замовлень — лічильник
  • Pull-to-refresh (потягнути вниз) — запускає синхронізацію

Путні листи (Waybills)

  • Список всіх путніх листів, відсортованих за датою
  • Бейдж статусу (Draft / Approved / Posted / Cancelled)
  • Натиснути на путній лист → деталь

В деталях путнього листа (редагування):

Поле Дії
Одометр (початок) Тільки читання
Одометр (кінець) ✏️ Вводить водій
Паливо на початку Тільки читання
Видано палива Тільки читання
Паливо в кінці ✏️ Вводить водій
Витрачено палива Розраховується автоматично, або можна ввести вручну

При введенні "Паливо в кінці" — "Витрачено" рахується автоматично:

Витрачено = (Паливо на початку + Видано) - Паливо в кінці

Натиснути "Зберегти" → зберігається локально → при наступній sync надсилається на сервер.

Путні листи зі статусом posted або cancelled — тільки для читання.

Замовлення (Orders)

  • Список замовлень на перевезення
  • Деталі: клієнт, маршрут, вантаж, вага, дата доставки, ціна

Замовлення тільки для читання в мобільному застосунку. Статус змінює диспетчер в DOP.

Профіль

  • Ім'я, email, телефон
  • Дані посвідчення водія (категорія, номер, дата закінчення)
  • Перемикач мови (🇺🇦 UA ↔ 🇬🇧 EN)
  • Кнопка "Вийти" — підтвердження → logout

Синхронізація

Ручна sync: - Dashboard → натиснути ↻ (кнопка справа вгорі) - Dashboard → потягнути список вниз (pull-to-refresh)

Автоматична sync: - При відкритті Dashboard (якщо є інтернет)

Офлайн: - Жовтий банер "Offline — showing cached data" вгорі екрану - Всі дані доступні з кешу - Введені зміни зберігаються локально - При відновленні зв'язку → ручна або автоматична sync


9. Sync: детально

Перша синхронізація (initial sync)

last_pulled_at = 0  (нуль = "дай все")

GET /api/v1/mobile/sync/pull?last_pulled_at=0

Response:
{
  "changes": {
    "waybills": {
      "created": [{ "id": "1", "number": "WB-001", ... }],
      "updated": [],
      "deleted": []
    },
    "orders": { "created": [...], "updated": [], "deleted": [] },
    ...
  },
  "timestamp": 1703000000000   ← зберігається як new lastPulledAt
}

WatermelonDB вставляє всі записи в локальну БД.

Інкрементальна синхронізація

last_pulled_at = 1703000000000  (час попередньої sync)

GET /api/v1/mobile/sync/pull?last_pulled_at=1703000000000

Response:
{
  "changes": {
    "waybills": {
      "created": [],
      "updated": [{ "id": "5", "status": "approved" }],  ← тільки що змінилось
      "deleted": []
    },
    ...
  },
  "timestamp": 1703100000000
}

WatermelonDB оновлює тільки змінені записи — ефективно.

Push (відправка змін водія)

POST /api/v1/mobile/sync/push

Body:
{
  "changes": {
    "waybills": {
      "created": [],
      "updated": [
        {
          "id": "3",
          "odometer_end": 152500,
          "fuel_end": 45.5,
          "fuel_consumed": 67.0,
          "distance": 380
        }
      ],
      "deleted": []
    }
  }
}

Сервер знаходить Waybill з id=3 і оновлює дозволені поля.

ID стратегія

Django використовує integer PK (1, 2, 3, ...). WatermelonDB потребує string ID. Сервер повертає str(pk) як ID:

'id': str(wb.id),  # "1", "2", "3"

WatermelonDB зберігає "1" як string ID. При push сервер конвертує назад:

wb = Waybill.objects.get(id=int(record['id']))  # "1" → 1


10. Конфігурація

Де змінити URL бекенду

Два файли (обидва треба змінити):

src/api/client.ts рядок 7:

const BASE_URL = __DEV__
  ? 'http://10.0.2.2:8000/api/v1'       // DEV (Android emulator)
  : 'https://api.eswf.dev/api/v1';      // PRODUCTION

src/api/sync.ts рядок 5:

const BASE_URL = __DEV__
  ? 'http://10.0.2.2:8000/api/v1'       // DEV
  : 'https://api.eswf.dev/api/v1';      // PRODUCTION

Для різних сценаріїв:

Сценарій URL
Android Emulator (localhost backend) http://10.0.2.2:8000/api/v1
iOS Simulator (localhost backend) http://localhost:8000/api/v1
Реальний телефон (Wi-Fi, backend на ноутбуці) http://192.168.X.X:8000/api/v1
Production https://api.eswf.dev/api/v1

Де змінити ім'я бази даних

src/database/index.ts:

dbName: 'eswf_driver',  // назва файлу SQLite на пристрої

Де змінити мову за замовчуванням

src/i18n/index.ts:

lng: 'ua',  // 'ua' або 'en'


11. Production збірка

Підготовка

1. Оновити app.json:

{
  "expo": {
    "name": "ESWF Driver",
    "version": "1.0.1",            інкрементувати при кожному релізі
    "ios": {
      "buildNumber": "2",          для iOS
      "bundleIdentifier": "dev.eswf.driver"
    },
    "android": {
      "versionCode": 2,            для Android (цілі числа, тільки збільшувати)
      "package": "dev.eswf.driver"
    }
  }
}

2. Змінити URL на production:

// src/api/client.ts і src/api/sync.ts
const BASE_URL = __DEV__ ? '...' : 'https://api.eswf.dev/api/v1';

3. Налаштувати CORS на бекенді — додати мобільний клієнт (по origin якщо є, або залишити без обмежень для API):

# backend/eswf/settings/production.py
CORS_ALLOWED_ORIGINS = ['https://erp.eswf.dev', 'https://shop.eswf.dev']

Збірка

# Android (APK для прямого розповсюдження)
eas build --platform android --profile preview

# Android (App Bundle для Google Play)
eas build --platform android --profile production

# iOS (потрібен Apple Developer аккаунт)
eas build --platform ios --profile production

OTA Updates (оновлення без перебілда)

Якщо зміни тільки в JS (не native):

eas update --branch production --message "Fix sync error handling"

Застосунок завантажить оновлення автоматично при наступному запуску.


12. Що ще можна додати

Функція Пріоритет Складність
Push-нотифікації (нове замовлення) Високий Середня
Фото вантажу (expo-camera) Середній Середня
GPS трекінг маршруту (expo-location) Середній Висока
Підпис водія (react-native-signature-canvas) Середній Низька
QR-сканер для ТТН Низький Низька
Автоматична sync по таймеру (background fetch) Низький Середня
Zod валідація форм Низький Низька
Друк документів (expo-print) Низький Середня
Темна/світла тема (system preference) Низький Низька
Drag-to-reorder маршрутних точок Низький Висока

Push-нотифікації (наступний крок)

npm install expo-notifications

Бекенд може надсилати нотифікацію коли диспетчер призначає нове замовлення водію. WatermelonDB тоді автоматично sync при отриманні.

GPS трекінг

npm install expo-location

Зберігати координати в новій таблиці location_logs WatermelonDB. Sync на сервер → відображення на карті в DOP.


Швидкий старт (TL;DR)

# 1. Встановити залежності
cd c:\eswf\mobile && npm install

# 2. Згенерувати native проекти
npx expo prebuild

# 3. Прив'язати Driver до User в Django:
#    python manage.py shell
#    >>> driver.user = user; driver.save()

# 4. Запустити бекенд
cd c:\eswf\backend
python manage.py runserver 8000

# 5. Запустити на Android emulator
cd c:\eswf\mobile
npx expo run:android

# 6. Або зібрати APK для телефону
eas build --platform android --profile preview

13. Sales App — короткий огляд

Локація: c:\eswf\mobile-sales\ Backend API: backend/sales_mobile_api//api/v1/sales-mobile/ Статус: ✅ Реалізовано (потребує expo prebuild)

Що вміє Sales App (відмінності від Driver)

Можливість Driver Sales
Офлайн-режим (WatermelonDB) ✅ 4 таблиці 23 таблиці
Перегляд призначених завдань ✅ Waybills, Orders ✅ Clients, Visits, DayPlan
Введення значень ✅ одометр, паливо ✅ Invoice + рядки + ціноутворення
GPS-трекінг ✅ background location, GPS-радіус 200м для compliance
Фото ✅ камера + галерея, до рахунків і візитів
Цифровий підпис react-native-signature-canvas
Офлайн-створення документів ❌ (тільки оновлення) ✅ повна форма Invoice з рядками, знижками, ПДВ
Push-сповіщення ✅ FCM через expo-notifications
Дзвінки клієнтам expo-linking (tel:) + CallLog
AI-чат + DM месенджер ✅ SSE streaming + WebSocket
Дашборд KPI / рейтинг

Структура mobile-sales/src/

api/          client.ts, auth.ts, sync.ts, photos.ts, chat.ts
auth/         AuthContext.tsx, tokenStorage.ts
database/     schema.ts (23 таблиці), index.ts, migrations.ts, models/ (25 моделей)
navigation/   AppNavigator.tsx — 7 bottom-tabs + nested stacks
screens/
  Clients/    ClientsListScreen, ClientDetailScreen
  Catalog/    CatalogScreen
  Invoices/   InvoicesListScreen, InvoiceFormScreen, InvoiceDetailScreen
  Visits/     VisitsListScreen, ActiveVisitScreen, DayPlanScreen
  Payments/   PaymentScreen
  Returns/    ReturnFormScreen, ReturnsListScreen
  Chat/       ChatListScreen, ChatSessionScreen
  Reports/    ReportsDashboardScreen
  + LoginScreen, DashboardScreen, ProfileScreen
components/   StatusBadge, DebtBadge, OfflineBanner, SyncIndicator,
              CallLogModal, ItemPickerModal
hooks/        useSync, useNetworkStatus, useGpsTracking,
              useChatStream, useRoomWebSocket
services/     GpsTracker.ts, PhotoService.ts, NotificationService.ts
i18n/         en.json, ua.json (~80 ключів)
theme.ts      Темна тема (узгоджена з DOP)

Backend sales_mobile_api/

9 моделей: SalesVisit, VisitPhoto, InvoicePhoto, CallLog, DayPlan + DayPlanItem, SalesReturn + SalesReturnLine, ClientSignature, DeviceToken.

Endpoints: /sync/pull, /sync/push, /me/, /photos/, /signatures/, /client-debt/, /reports/dashboard/, /reports/ranking/, /register-device/.

Зв'язок з Essentials: до Client додано FK manager (= sales_rep), за яким фільтруються дані синхронізації.

Супервізор торгових представників (web, не mobile)

Серверна частина для керівника ТП — у Django app sales_field/, frontend у DOP (SalesFieldManager.tsx). Можливості: KPI-дашборд, день-плани, копіювання тижня, GPS-радіус 200м для compliance, рейтинг ТП. Деталі — CLAUDE.md.

Запуск Sales App

cd c:\eswf\mobile-sales
npm install
npx expo prebuild
npx expo run:android

Публікація

eas build --platform android --profile production
eas submit --platform android

Документація оновлена: 2026-04-21 (додано Sales App overview)