824 lines
32 KiB
Markdown
824 lines
32 KiB
Markdown
# 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<Asset>`
|
|
- `GET /api/assets/{id}` → `AssetDetail`
|
|
- `GET /api/assets/search?machine_id=` → `List<Asset>`
|
|
- `POST /api/checkins` → `Checkin`
|
|
- `GET /api/checkins?asset_id=&limit=` → `List<Checkin>`
|
|
- `GET /api/proximity?lat=&lng=&radius_meters=` → `List<Asset>`
|
|
- `POST /api/geofences/check` → `List<Geofence>`
|
|
- `GET /api/geofences` → `List<Geofence>`
|
|
- `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
|
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
```
|
|
|
|
**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<ProximityEvent>`
|
|
- Expose `getActiveProximities(): StateFlow<List<Asset>>`
|
|
|
|
**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<Boolean>`
|
|
- 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
|