# Canteen Asset Tracker — Android App Implementation Plan > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. **Goal:** Native Android app that uses device GPS to detect when a technician is near a machine, enabling background proximity alerts, automatic check-in prompts, and offline-capable asset lookup — all backed by the existing FastAPI backend. **Architecture:** Kotlin + Jetpack Compose native Android app communicating with the existing FastAPI backend via REST. FusedLocationProviderClient for efficient background GPS. GeofencingClient for proximity zones defined by asset/location coordinates. Firebase Cloud Messaging (FCM) for push notifications. Room database for offline cache. **Tech Stack:** - Language: Kotlin 2.0+ - UI: Jetpack Compose (Material 3) - Navigation: Compose Navigation - Networking: Retrofit 2 + OkHttp + kotlinx.serialization - Location: Google Play Services (FusedLocationProvider + GeofencingClient) - Local DB: Room (offline asset cache, pending check-ins) - Camera: CameraX (barcode scanning + OCR photo upload) - Maps: Google Maps Compose / OSMDroid (open-source fallback) - Push: Firebase Cloud Messaging - DI: Hilt - Target: Android API 26+ (covers 96%+ devices) --- ## Phase 0: Backend Prerequisites The existing backend at `~/projects/canteen-asset-tracker/server.py` needs GPS-coordinate storage and a proximity-check API before the Android app can function. These are gating changes. ### Task 0.1: Add latitude/longitude columns to assets and locations tables **Objective:** Store GPS coordinates for each machine and location so the Android app can check proximity. **Files:** - Modify: `server.py:_create_v2_tables` (assets + locations table DDL) - Modify: `server.py:` migration block (add ALTER TABLE if missing) - Modify: `server.py:` AssetCreate/AssetUpdate Pydantic models - Modify: `server.py:` LocationCreate/LocationUpdate Pydantic models - Modify: `tests/test_server.py` **Step 1: Add columns to table creation** In `_create_v2_tables()` (around line 77), add to `locations`: ```sql latitude REAL DEFAULT NULL, longitude REAL DEFAULT NULL, ``` In `_create_v2_tables()` (around line 136), add to `assets`: ```sql latitude REAL DEFAULT NULL, longitude REAL DEFAULT NULL, geofence_radius_meters INTEGER DEFAULT 50, ``` **Step 2: Add migration logic** After the existing migration block (around line 340), add: ```python # Add lat/lng to assets if not present (v3 migration) cursor = conn.execute("PRAGMA table_info(assets)") asset_cols = {row[1] for row in cursor.fetchall()} if "latitude" not in asset_cols: conn.execute("ALTER TABLE assets ADD COLUMN latitude REAL DEFAULT NULL") if "longitude" not in asset_cols: conn.execute("ALTER TABLE assets ADD COLUMN longitude REAL DEFAULT NULL") if "geofence_radius_meters" not in asset_cols: conn.execute("ALTER TABLE assets ADD COLUMN geofence_radius_meters INTEGER DEFAULT 50") cursor = conn.execute("PRAGMA table_info(locations)") loc_cols = {row[1] for row in cursor.fetchall()} if "latitude" not in loc_cols: conn.execute("ALTER TABLE locations ADD COLUMN latitude REAL DEFAULT NULL") if "longitude" not in loc_cols: conn.execute("ALTER TABLE locations ADD COLUMN longitude REAL DEFAULT NULL") ``` **Step 3: Update Pydantic models** Add optional fields to `AssetCreate` and `AssetUpdate`: ```python latitude: Optional[float] = None longitude: Optional[float] = None geofence_radius_meters: Optional[int] = 50 ``` Add optional fields to `LocationCreate` and `LocationUpdate`: ```python latitude: Optional[float] = None longitude: Optional[float] = None ``` **Step 4: Update create/update handlers** Modify `POST /api/assets`, `PUT /api/assets/{id}`, `POST /api/locations`, `PUT /api/locations/{id}` to include the new optional fields in INSERT/UPDATE SQL. **Step 5: Run tests** Run: `cd ~/projects/canteen-asset-tracker && python -m pytest tests/test_server.py -v -x` Expected: All 319 existing tests pass (new columns have defaults, existing INSERTs unaffected) **Step 6: Add tests for new fields** Add ~6 new tests verifying: - Create asset with lat/lng - Update asset lat/lng - Create location with lat/lng - Update location lat/lng - GET returns lat/lng fields - geofence_radius_meters defaults to 50 ### Task 0.2: Add proximity-check API endpoint **Objective:** Backend endpoint that takes a GPS point and returns nearby assets sorted by distance. **Files:** - Modify: `server.py` (new `GET /api/proximity` endpoint) - Modify: `tests/test_server.py` (proximity tests) **Step 1: Write failing test** ```python def test_proximity_returns_nearby_assets(client, seed_db): # Seed assets with coordinates resp = client.post("/api/assets", json={ "machine_id": "PROX-000001", "name": "Nearby Fridge", "category": "Appliances", "latitude": 40.7128, "longitude": -74.0060, # NYC "geofence_radius_meters": 100, }) assert resp.status_code == 201 resp = client.post("/api/assets", json={ "machine_id": "PROX-000002", "name": "Far Freezer", "category": "Appliances", "latitude": 40.8000, "longitude": -74.1000, # ~10km away "geofence_radius_meters": 100, }) assert resp.status_code == 201 # Query from near the first asset resp = client.get("/api/proximity?lat=40.7129&lng=-74.0061&radius_meters=200") assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 assert data[0]["machine_id"] == "PROX-000001" # Closest first ``` **Step 2: Run test to verify failure** Run: `pytest tests/test_server.py::test_proximity_returns_nearby_assets -v` Expected: FAIL — 404 Not Found **Step 3: Implement the endpoint** ```python @app.get("/api/proximity") def proximity_check( lat: float = Query(...), lng: float = Query(...), radius_meters: int = Query(200, ge=1, le=50000), ): """ Return assets within radius_meters of (lat, lng), sorted by distance. Uses Haversine formula for accurate spherical distance. """ conn = get_db() rows = conn.execute(""" SELECT *, ( 6371000 * acos( cos(radians(?)) * cos(radians(latitude)) * cos(radians(longitude) - radians(?)) + sin(radians(?)) * sin(radians(latitude)) ) ) AS distance_meters FROM assets WHERE latitude IS NOT NULL AND longitude IS NOT NULL HAVING distance_meters <= ? ORDER BY distance_meters LIMIT 50 """, (lat, lng, lat, radius_meters)).fetchall() conn.close() results = [row_to_dict(r) for r in rows] return results ``` **Step 4: Run test to verify pass** Run: `pytest tests/test_server.py::test_proximity_returns_nearby_assets -v` Expected: PASS **Step 5: Add edge case tests** - No nearby assets returns `[]` - Missing lat/lng params returns 422 - radius_meters below 1 or above 50000 returns 422 - Assets with NULL lat/lng are excluded **Step 6: Commit** ```bash git add server.py tests/test_server.py git commit -m "feat: add lat/lng to assets/locations + proximity API" ``` ### Task 0.3: Add geofence point-check API endpoint **Objective:** Endpoint that checks whether a given GPS point falls inside any geofence polygon. **Files:** - Modify: `server.py` (new `POST /api/geofences/check` endpoint) - Modify: `tests/test_server.py` (geofence check tests) **Step 1: Implement the endpoint** ```python @app.post("/api/geofences/check") def check_geofence_point(body: GeofencePointCheck): """ Check if a GPS point falls inside any geofence polygon. Returns list of matching geofences. """ conn = get_db() rows = conn.execute("SELECT * FROM geofences ORDER BY name").fetchall() conn.close() matches = [] for row in rows: points = _json.loads(row["points"]) if _point_in_polygon(body.lat, body.lng, points): matches.append(row_to_dict(row)) return matches def _point_in_polygon(lat: float, lng: float, polygon: list) -> bool: """Ray-casting algorithm for point-in-polygon test.""" inside = False n = len(polygon) j = n - 1 for i in range(n): yi = polygon[i]["lat"] if isinstance(polygon[i], dict) else polygon[i][0] xi = polygon[i]["lng"] if isinstance(polygon[i], dict) else polygon[i][1] yj = polygon[j]["lat"] if isinstance(polygon[j], dict) else polygon[j][0] xj = polygon[j]["lng"] if isinstance(polygon[j], dict) else polygon[j][1] if ((yi > lng) != (yj > lng)) and (lat < (xj - xi) * (lng - yi) / (yj - yi) + xi): inside = not inside j = i return inside ``` **Step 2: Add Pydantic model** ```python class GeofencePointCheck(BaseModel): lat: float lng: float ``` **Step 3: Write tests** (~5 tests: inside, outside, empty geofences, multiple matches) **Step 4: Commit** --- ## Phase 1: Android Project Scaffold ### Task 1.1: Create Android project **Objective:** Boot a bare Kotlin + Compose project with Gradle Kotlin DSL. **Files:** - Create: `android/` directory in project root - Create: `android/build.gradle.kts` (project-level) - Create: `android/app/build.gradle.kts` (module-level) - Create: `android/gradle.properties` - Create: `android/settings.gradle.kts` - Create: `android/app/src/main/AndroidManifest.xml` **Dependencies in app/build.gradle.kts:** ```kotlin dependencies { // Compose BOM implementation(platform("androidx.compose:compose-bom:2024.06.00")) implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.0") // Navigation implementation("androidx.navigation:navigation-compose:2.7.7") // Networking implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") // Location implementation("com.google.android.gms:play-services-location:21.3.0") // CameraX implementation("androidx.camera:camera-camera2:1.3.4") implementation("androidx.camera:camera-lifecycle:1.3.4") implementation("androidx.camera:camera-view:1.3.4") // Local DB implementation("androidx.room:room-runtime:2.6.1") ksp("androidx.room:room-compiler:2.6.1") implementation("androidx.room:room-ktx:2.6.1") // DI implementation("com.google.dagger:hilt-android:2.51.1") ksp("com.google.dagger:hilt-compiler:2.51.1") implementation("androidx.hilt:hilt-navigation-compose:1.2.0") // Maps (OSMDroid — open-source, no API key) implementation("org.osmdroid:osmdroid-android:6.1.18") // Testing testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.compose.ui:ui-test-junit4") } ``` **Step 1:** `mkdir -p ~/projects/canteen-asset-tracker/android` **Step 2:** Write all scaffold files **Step 3:** Verify: `cd android && ./gradlew assembleDebug` succeeds **Step 4: Commit** ```bash git add android/ git commit -m "feat: Android project scaffold (Kotlin + Compose)" ``` ### Task 1.2: Networking layer — Retrofit API client **Objective:** Define typed API client for all backend endpoints the Android app needs. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/network/CanteenApi.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/network/dto/*.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/network/RetrofitModule.kt` (Hilt DI) **API endpoints to model:** - `POST /api/auth/login` → `AuthToken` - `GET /api/auth/me` → `User` - `GET /api/assets` → `List` - `GET /api/assets/{id}` → `AssetDetail` - `GET /api/assets/search?machine_id=` → `List` - `POST /api/checkins` → `Checkin` - `GET /api/checkins?asset_id=&limit=` → `List` - `GET /api/proximity?lat=&lng=&radius_meters=` → `List` - `POST /api/geofences/check` → `List` - `GET /api/geofences` → `List` - `POST /api/ocr` (multipart) → `OcrResult` **DTOs:** `LoginRequest`, `AuthResponse`, `User`, `Asset`, `AssetDetail`, `CheckinCreate`, `Checkin`, `GeofencePointCheck`, `Geofence`, `OcrResult`, `ProximityResponse` **Step 1:** Write all DTOs as `@Serializable` data classes **Step 2:** Write `CanteenApi` interface with Retrofit annotations **Step 3:** Write Hilt `@Module` for providing Retrofit instance with auth token interceptor **Step 4:** Write unit test mocking OkHttp (verify auth header injection) **Step 5: Commit** ### Task 1.3: Local database — Room cache **Objective:** Offline cache of assets, pending check-ins, and auth token. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/data/AppDatabase.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/data/entity/*.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/data/dao/*.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/data/DatabaseModule.kt` (Hilt DI) **Entities:** - `CachedAsset` — mirrors API asset with lat/lng, last_synced timestamp - `PendingCheckin` — check-ins created offline, synced when online - `AuthSession` — token, user_id, role, expiry **DAOs:** - `AssetDao` — insertAll, getAll, searchByName, getById, clearAll - `PendingCheckinDao` — insert, getAllPending, markSynced, delete - `AuthSessionDao` — insert, getActive, invalidate **Step 1:** Write entities and DAOs **Step 2:** Write AppDatabase (Room 2.6.1, schema export on) **Step 3:** Write Hilt module **Step 4:** Write instrumentation test: insert → query → verify **Step 5: Commit** --- ## Phase 2: Core Android Features ### Task 2.1: Login screen + auth flow **Objective:** Login screen that authenticates against the backend, stores token securely, and gates the rest of the app. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/ui/login/LoginScreen.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/ui/login/LoginViewModel.kt` - Modify: `android/app/src/main/java/com/canteen/assettracker/MainActivity.kt` - Modify: `android/app/src/main/java/com/canteen/assettracker/navigation/NavGraph.kt` **UI:** Username + password fields, Sign In button, error banner, "Connecting..." loading state. Dark theme matching webapp. Server URL configurable via settings (default: https://canteen.ourpad.casa:8901). **Auth flow:** 1. POST /api/auth/login → get bearer token 2. Store token in Room + OkHttp interceptor 3. GET /api/auth/me → show user badge 4. Navigate to main screen **Step 1:** Write LoginViewModel (StateFlow: isLoading, error, token) **Step 2:** Write LoginScreen composable **Step 3:** Wire into NavGraph (login → authenticated graph) **Step 4:** Write `RetrofitModule` auth interceptor (reads token from Room) **Step 5:** Compose preview + manual test on device/emulator **Step 6: Commit** ### Task 2.2: Home dashboard screen **Objective:** Dashboard with stats (total assets, by category, recent check-ins). Data loaded from backend, cached in Room. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/ui/dashboard/DashboardScreen.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/ui/dashboard/DashboardViewModel.kt` **UI:** Stats cards (total, active, maintenance), category breakdown bar, recent check-ins list, pull-to-refresh. **Step 1:** Write DashboardViewModel (loads assets + checkins on init) **Step 2:** Write DashboardScreen (LazyColumn with stat cards) **Step 3:** Error state — show retry button when offline **Step 4:** Commit ### Task 2.3: Asset list + search + detail **Objective:** Browse all assets, search by name/machine_id, view full asset detail with map pin. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/ui/assets/AssetListScreen.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/ui/assets/AssetDetailScreen.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/ui/assets/AssetViewModel.kt` **UI:** - List: search bar + LazyColumn cards (name, machine_id, status chip, category icon) - Detail: full fields, map showing pin if lat/lng set, "Check In" FAB button **Step 1:** AssetViewModel (load, search local + remote, cache) **Step 2:** AssetListScreen **Step 3:** AssetDetailScreen with map **Step 4:** Commit ### Task 2.4: Map screen with asset pins **Objective:** Full map showing all asset markers, geofence overlays, and current GPS position. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/ui/map/MapScreen.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/ui/map/MapViewModel.kt` **UI:** OSMDroid map view, asset markers (tappable → asset detail), geofence polygon overlays, current-location blue dot, "center on me" button. **Step 1:** MapViewModel (load geofences + assets with lat/lng) **Step 2:** MapScreen composable with `MapView` AndroidView wrapper **Step 3:** Marker click → navigate to AssetDetail **Step 4:** Current location dot **Step 5:** Commit --- ## Phase 3: GPS Proximity Detection (Core Feature) ### Task 3.1: Background location service **Objective:** Foreground service that continuously tracks device location using FusedLocationProviderClient. Runs even when app is in background. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/location/LocationService.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/location/LocationRepository.kt` - Modify: `android/app/src/main/AndroidManifest.xml` (add FOREGROUND_SERVICE + location permissions) **Permissions added to manifest:** ```xml ``` **LocationService behavior:** - Request location updates every 10 seconds (configurable: 5-60s) - Priority: PRIORITY_HIGH_ACCURACY (GPS) - Emit location to a shared Kotlin `SharedFlow` - Show persistent notification: "Canteen Tracker — monitoring location" - Auto-restart on device boot (BootReceiver) **Step 1:** Write LocationRepository (wraps FusedLocationProviderClient) **Step 2:** Write LocationService (ForegroundService) **Step 3:** Register in AndroidManifest **Step 4:** Add runtime permission request flow in MainActivity **Step 5:** Test: start service, background app, verify location updates in logcat **Step 6: Commit** ### Task 3.2: Proximity engine — detect when near a machine **Objective:** Core logic that continuously checks device location against asset/location coordinates and triggers notifications when entering proximity zones. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/location/ProximityEngine.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/location/ProximityRepository.kt` **ProximityEngine algorithm:** ``` Every location update (debounced to 5 seconds): 1. If user is moving (speed > 1 m/s), skip (save battery) 2. Query local Room cache for assets within 500m (pre-filter using simple lat/lng box) 3. Compute Haversine distance for each candidate 4. If distance < asset.geofence_radius_meters AND asset not in activeProximities: → Emit "entered proximity" event → Show notification: "Near MachineName (50m) — tap to check in" → Add to activeProximities set 5. If distance > asset.geofence_radius_meters * 1.5 AND asset in activeProximities: → Emit "exited proximity" event → Remove from activeProximities 6. Also check geofences: call POST /api/geofences/check every 60 seconds ``` **ProximityRepository:** - Cache assets with lat/lng locally - Periodically sync assets from server (every 5 min) - Expose `observeProximityEvents(): Flow` - Expose `getActiveProximities(): StateFlow>` **Step 1:** Implement Haversine distance function (in km, returns meters) ```kotlin fun haversineDistance(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double { val R = 6371000.0 // Earth radius in meters val dLat = Math.toRadians(lat2 - lat1) val dLng = Math.toRadians(lng2 - lng1) val a = sin(dLat / 2) * sin(dLat / 2) + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLng / 2) * sin(dLng / 2) val c = 2 * atan2(sqrt(a), sqrt(1 - a)) return R * c } ``` **Step 2:** Write ProximityEngine with the full check loop **Step 3:** Write ProximityRepository (local cache + periodic sync) **Step 4:** Unit test: mock locations, verify proximity events fire correctly **Step 5:** Commit ### Task 3.3: Push notifications for proximity alerts **Objective:** When device enters a machine's proximity zone, show a local notification with quick-action buttons (Check In, Dismiss). **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/notification/NotificationHelper.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/notification/ProximityNotificationReceiver.kt` **Notification channels:** - `proximity_alerts` — high importance, heads-up, sound + vibration - `checkin_reminders` — default importance **Proximity notification content:** - Title: "Near MachineName" - Body: "~50m away at 123 Main St — Room 3A" - Actions: "Check In" (opens check-in screen or auto-checks-in), "Dismiss" (snoozes for 5 min) - Tap: opens AssetDetailScreen **Step 1:** Write NotificationHelper (channel creation, notification builder) **Step 2:** Wire ProximityEngine events → NotificationHelper **Step 3:** Implement BroadcastReceiver for notification actions **Step 4:** Commit ### Task 3.4: Check-in flow from proximity alert **Objective:** When user taps "Check In" on a proximity notification, auto-create a check-in with current GPS coordinates and minimal interaction. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/ui/checkin/QuickCheckinViewModel.kt` - Modify: ProximityNotificationReceiver **Flow:** 1. User taps "Check In" on notification or FAB on asset detail 2. QuickCheckinScreen shows asset name, current GPS coords, accuracy 3. Optional: photo capture (CameraX), notes field 4. "Submit" → POST /api/checkins 5. On success: toast + dismiss notification + remove from activeProximities 6. On failure (offline): save to PendingCheckin table, retry on connectivity **Step 1:** QuickCheckinViewModel **Step 2:** QuickCheckinScreen (sheet/bottom sheet) **Step 3:** Pending check-in retry logic (WorkManager periodic task) **Step 4:** Commit --- ## Phase 4: Camera & OCR ### Task 4.1: Barcode scanner (CameraX + ML Kit) **Objective:** Scan machine_id barcodes using device camera. Same as webapp scan tab but native Android performance. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/ui/scan/ScanScreen.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/ui/scan/ScanViewModel.kt` **Stack:** CameraX + Google ML Kit Barcode Scanning (on-device, no network needed). Alternative: ZXing (same as webapp) via `com.journeyapps:zxing-android-embedded`. **Flow:** 1. Open ScanScreen → CameraX preview with barcode overlay 2. Detect barcode → extract machine_id 3. Call `GET /api/assets/search?machine_id=...` 4. If found → navigate to AssetDetail 5. If not found → prompt "Add new asset?" **Step 1:** ScanViewModel with ML Kit integration **Step 2:** ScanScreen with CameraX preview + barcode bounding box **Step 3:** Manual entry fallback (type machine_id) **Step 4:** Commit ### Task 4.2: OCR photo upload for sticker IDs **Objective:** Take photo of sticker, upload to backend OCR endpoint, display extracted machine_id. **Files:** - Modify: `ScanScreen.kt` (add "Photo OCR" tab/mode) - Create: `android/app/src/main/java/com/canteen/assettracker/ui/scan/OcrViewModel.kt` **Flow:** 1. Switch to OCR mode in ScanScreen 2. Tap capture button → CameraX takePicture() 3. Show preview, confirm or retake 4. Upload as multipart to `POST /api/ocr` 5. Show extracted machine_id + confidence 6. Offer "Look up asset" button **Step 1:** OcrViewModel (multipart upload via Retrofit) **Step 2:** Photo capture + preview UI **Step 3:** Commit --- ## Phase 5: Polish & Release ### Task 5.1: Offline mode handling **Objective:** Gracefully handle no-network states. Show cached data, queue mutations. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/util/ConnectivityObserver.kt` - Modify: All ViewModels (add offline-aware data sources) **Approach:** - `ConnectivityObserver` monitors `ConnectivityManager` → emits `StateFlow` - When offline: load from Room, show "Offline" banner - Pending check-ins saved to Room, synced via WorkManager when connectivity returns - Asset list shows last-synced timestamp **Step 1:** ConnectivityObserver **Step 2:** Offline data-source layer in repositories **Step 3:** WorkManager sync worker **Step 4:** Commit ### Task 5.2: Settings screen **Objective:** Server URL config, GPS update interval, geofence radius defaults, dark mode toggle, logout. **Files:** - Create: `android/app/src/main/java/com/canteen/assettracker/ui/settings/SettingsScreen.kt` - Create: `android/app/src/main/java/com/canteen/assettracker/ui/settings/SettingsViewModel.kt` **Settings stored via DataStore (Preferences):** - `server_url` — default: `https://canteen.ourpad.casa:8901` - `gps_interval_seconds` — default: 10, range: 5-60 - `proximity_scan_radius_meters` — default: 500 - `dark_mode` — boolean (app is dark by default) - `offline_mode` — boolean (skip network calls, use Room only) **Step 1:** DataStore preferences **Step 2:** SettingsScreen composable **Step 3:** Wire settings into LocationService and ProximityEngine **Step 4:** Commit ### Task 5.3: Self-signed cert trust **Objective:** The backend uses a self-signed TLS cert. OkHttp must trust it. **Files:** - Modify: `RetrofitModule.kt` **Approach:** Bundle `cert.pem` as raw resource. Create custom TrustManager that trusts the bundled cert. Alternatively: add `network_security_config.xml` for dev builds. **Step 1:** Copy `~/projects/canteen-asset-tracker/cert.pem` → `android/app/src/main/res/raw/cert.pem` **Step 2:** Update OkHttpClient builder to trust the custom cert **Step 3:** Commit ### Task 5.4: Signed APK build + distribution **Objective:** Produce a signed release APK for distribution. **Files:** - Create: `android/keystore.properties` (gitignored) - Modify: `android/app/build.gradle.kts` (signing config) **Step 1:** Generate release keystore: `keytool -genkey -v -keystore canteen-release.jks ...` **Step 2:** Signing config in build.gradle.kts **Step 3:** `./gradlew assembleRelease` → `app/build/outputs/apk/release/app-release.apk` **Step 4:** Document installation: sideload APK on Android device **Step 5: Commit** --- ## Phase 6: Testing ### Task 6.1: Unit tests (ViewModels, repositories, proximity engine) **Objective:** Test core logic without device dependencies. **Files:** - Create: `android/app/src/test/java/com/canteen/assettracker/location/ProximityEngineTest.kt` - Create: `android/app/src/test/java/com/canteen/assettracker/ui/login/LoginViewModelTest.kt` - Create: `android/app/src/test/java/com/canteen/assettracker/ui/dashboard/DashboardViewModelTest.kt` **Test targets:** - LoginViewModel: successful login, auth failure, network error - ProximityEngine: inside geofence triggers event, outside doesn't, exit event, debounce - DashboardViewModel: loads stats, handles empty, refresh - Haversine distance: known coordinates (NYC → LA ~3944 km) **Step 1:** Write ProximityEngineTest **Step 2:** Write LoginViewModelTest **Step 3:** Write DashboardViewModelTest **Step 4:** Verify: `./gradlew test` — all pass **Step 5: Commit** ### Task 6.2: Instrumentation tests (Room, Compose UI) **Objective:** Test database operations and Compose UI on device/emulator. **Files:** - Create: `android/app/src/androidTest/java/com/canteen/assettracker/data/AssetDaoTest.kt` - Create: `android/app/src/androidTest/java/com/canteen/assettracker/ui/login/LoginScreenTest.kt` **Step 1:** AssetDaoTest (insert, query, search, clear) **Step 2:** LoginScreenTest (fill fields, tap sign in, verify navigation) **Step 3:** Verify: `./gradlew connectedAndroidTest` **Step 4: Commit** --- ## Technical Decisions & Tradeoffs | Decision | Rationale | Alternative considered | |---|---|---| | Kotlin + Compose | Native performance, best GPS/BG service support | Flutter (BG services trickier), React Native (same) | | OSMDroid (not Google Maps) | No API key required, works offline | Google Maps Compose (richer, needs billing) | | Room (not SQLDelight) | Mature, Hilt integration, Compose-friendly | SQLDelight (KMP-friendly, overkill here) | | FusedLocationProvider | Battery-efficient, Google Play Services | Raw GPS (`LocationManager`) — worse battery | | Haversine client-side | No server round-trip per location update, instant | Server-side proximity (more accurate, network-dependent) | | Local notifications (not FCM) | No Firebase dependency for basic alerts, works offline | FCM push (needed for server-triggered alerts in future) | | Self-signed cert bundling | Backend uses self-signed TLS, must trust it for Retrofit | Let's Encrypt cert on backend (preferred long-term) | ## Risks - **Background process killed by Android:** Android 14+ aggressively kills background services. Mitigation: Foreground service with persistent notification. Test on Android 14/15. - **Battery drain:** GPS every 10 seconds can drain battery. Mitigation: debounce when stationary, increase interval when battery low, use geofencing API for wide zones. - **Self-signed cert:** Android 14+ blocks user-added CAs by default for apps targeting SDK 34+. Mitigation: network_security_config.xml for debug, or switch backend to Let's Encrypt. - **Location permissions:** Users may deny background location — app becomes foreground-only. Mitigation: degrade gracefully, explain why background access is needed. ## Open Questions 1. Should proximity also check locations (buildings) in addition to individual assets? 2. Should the app support multiple backend server profiles (e.g., different customer sites)? 3. Do we need Firebase Cloud Messaging for server-pushed alerts, or are local notifications sufficient for v1? 4. Should the app upload check-in photos immediately or queue them for WiFi-only upload? --- ## Implementation Order (Recommended) ``` Phase 0 (Backend): Tasks 0.1 → 0.2 → 0.3 [prerequisite for all Android work] Phase 1 (Scaffold): Tasks 1.1 → 1.2 → 1.3 [sequential, no parallelism] Phase 2 (Core UI): Tasks 2.1 → (2.2, 2.3, 2.4) [2.2+2.3+2.4 can run in parallel after 2.1] Phase 3 (GPS Engine): Tasks 3.1 → 3.2 → 3.3 → 3.4 [sequential dependencies] Phase 4 (Camera): Tasks 4.1, 4.2 [can run in parallel] Phase 5 (Polish): Tasks 5.1 → 5.2 → 5.3 → 5.4 [sequential] Phase 6 (Testing): Tasks 6.1, 6.2 [can run in parallel] ``` **Estimated total effort:** ~25-35 hours of focused development. --- ## Verification Checklist - [ ] Backend: proximity API returns correct nearest assets - [ ] Backend: geofence point-check works (inside/outside polygon) - [ ] Android: app installs on API 26+ device - [ ] Android: login flow completes against real backend - [ ] Android: GPS location updates appear in logcat (foreground + background) - [ ] Android: proximity notification fires when walking within 50m of a machine with coordinates - [ ] Android: "Check In" from notification creates check-in on backend - [ ] Android: asset list loads and displays from backend - [ ] Android: barcode scan finds asset by machine_id - [ ] Android: offline mode shows cached assets, queues check-ins - [ ] Android: queued check-ins sync when connectivity returns - [ ] Android: release APK installs and runs on a real device