# RoadWise Log — build spec

A mileage tracker that prioritizes battery-efficient activity/geofence detection, upfront free-limit visibility, and a much lower $2.50/month price.

## Project context

This spec describes an independent, alternative Android app you are building from scratch to compete with an existing incumbent app on Google Play — not a modification, clone, or reskin of the incumbent's own code, assets, or branding. Use the incumbent only as a market reference (via the report data below), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** Mileage Tracker by Driversnote
- **Package id:** `com.driversnote.driversnote`
- **Google Play:** https://play.google.com/store/apps/details?id=com.driversnote.driversnote
- **appy.fyi report:** https://appy.fyi/report/com.driversnote.driversnote
- **Category:** Finance
- **Price trend:** steady at USD 0.00 since 2026-08-14

## Overview

- **Working name:** RoadWise Log (trademark cleared: no)
- **Package id:** `fyi.appy.roadwiselog`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 5 weeks
- **Pricing:** subscription, $2.5 via `revenuecat`
- **Runtime AI:** none
- **Permissions:** `ACCESS_FINE_LOCATION`, `POST_NOTIFICATIONS`, `INTERNET`

## Non-goals (out of scope for v1)

- No continuous all-day GPS polling while the user is not driving.
- No fleet, employer, team, dispatcher, or multi-driver account features in v1.
- No cloud sync, web dashboard, or account system in v1.
- No OBD hardware, Bluetooth beacon, or vehicle dongle integration.
- No tax deduction guarantee or tax advice beyond producing mileage log exports.
- No AI trip classification or AI report generation.

## Tech stack

- **Language / UI:** Kotlin, Jetpack Compose
- **Kotlin:** 2.0.21
- **Compose BOM:** 2024.10.01
- **Gradle:** 8.9

| Purpose | Gradle coordinate |
| --- | --- |
| Android Kotlin extensions and compatibility helpers | `androidx.core:core-ktx:1.15.0` |
| Compose activity host | `androidx.activity:activity-compose:1.9.3` |
| Compose version alignment | `androidx.compose:compose-bom:2024.10.01` |
| Material 3 Compose UI components | `androidx.compose.material3:material3:1.3.1` |
| Compose navigation graph | `androidx.navigation:navigation-compose:2.8.4` |
| Lifecycle-aware Compose state collection | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| Compose ViewModel integration | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7` |
| Local trip database runtime | `androidx.room:room-runtime:2.6.1` |
| Room coroutine and Flow support | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| Activity Recognition, Geofencing, and Fused Location Provider APIs | `com.google.android.gms:play-services-location:21.3.0` |
| Coroutines on Android | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| Await Google Play Services Task results from coroutines | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| Subscription entitlement and Google Play Billing wrapper | `com.revenuecat.purchases:purchases:8.10.4` |
| Write XLSX mileage report files without Microsoft Office | `org.dhatim:fastexcel:0.18.4` |

## Design system

- **Primary color:** `#1B6E5C`
- **Background color:** `#F7FAF8`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `car` (regular weight)
- **Theme notes:** Use Material 3 dynamic color disabled for brand consistency. Light theme background is #F7FAF8 with dark text #1B1C1B. Dark theme background is #101412 with primary #7EDBC6 and surface #1A1F1C. Use rounded 16dp cards for trips and reports, 24dp primary buttons, and green accent only for active tracking or paid/unlocked state.

## Screens

### Onboarding
- **Route:** `onboarding`
- **Purpose:** Explain the battery-efficient tracking model, request required permissions, and show the free trip limit before the user starts.
- **Reached via:** app launch when onboardingComplete is false; tap Permission setup from Settings
- **Key UI elements:** Headline: Battery-light mileage tracking; Three bullets: uses driving detection, does not poll GPS all day, shows the free limit upfront; Permission status rows for precise location, activity recognition, and notifications; Free limit card: 15 free trips per month; Primary button: Continue; Secondary button: Not now
- **States:** loading permission statuses, missing required permissions, all required permissions granted, permission permanently denied with settings link, error reading permission state

### Dashboard
- **Route:** `dashboard`
- **Purpose:** Show current trip-detection state, visible monthly free-trip usage, and recent trips.
- **Reached via:** app launch after onboarding; tap Trips bottom navigation item; system notification tap while trip is recording
- **Key UI elements:** Tracking status banner: Idle, Detecting driving, Recording trip, or Free limit reached; Monthly free usage counter, for example 8 of 15 free trips used this month; Recent trip list cards with date, distance, category, and status; Upgrade button when entitlement is inactive; Bottom navigation items: Trips, Reports, Settings
- **States:** loading, empty with no trips yet, populated with recent trips, free limit reached, tracking active, error loading trips

### TripDetail
- **Route:** `trip/{tripId}`
- **Purpose:** Review one detected trip, edit its category/note, and confirm or discard it.
- **Reached via:** tap a trip card on Dashboard; tap a newly completed trip notification
- **Key UI elements:** Trip date and time range; Distance in miles; Start and end coordinate text; Category selector: Business, Personal, Medical, Charity, Unclassified; Note text field; Save button; Discard trip button
- **States:** loading, populated, trip not found, saving, save error

### Reports
- **Route:** `reports`
- **Purpose:** Create IRS-style mileage logs as PDF or Excel files for a chosen date range.
- **Reached via:** tap Reports bottom navigation item; tap Export from Dashboard overflow
- **Key UI elements:** Date range selector with current month default; Category filter chips; Summary row with total miles and trip count; Export PDF button; Export Excel button; Last export result message
- **States:** loading, empty for selected range, populated, exporting, export success, export error

### Paywall
- **Route:** `paywall`
- **Purpose:** Present the fair-price subscription and unlock tracking beyond the free monthly trip limit.
- **Reached via:** tap Upgrade on Dashboard; tap Upgrade when free limit is reached; tap Subscribe from Settings
- **Key UI elements:** Price headline: $2.50/month; Comparison copy: well below $10-20/month mileage tracker pricing; Included benefits list: more than 15 trips/month, PDF export, Excel export; Subscribe button; Restore purchases button; Close button
- **States:** loading offering, offering loaded, purchase in progress, purchase success, purchase cancelled, purchase error, restore success, restore found nothing

### Settings
- **Route:** `settings`
- **Purpose:** Show subscription state, permission state, export/legal links, and tracking configuration status.
- **Reached via:** tap Settings bottom navigation item
- **Key UI elements:** Subscription status row; Restore purchases button; Permission setup row; Battery-efficient tracking explanation; Privacy policy link; App version text
- **States:** loading, populated, restore in progress, restore error, permission missing warning

## Data model

### Trip (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| startedAt | `Instant` | UTC timestamp; Room TypeConverter stores epoch milliseconds |
| endedAt | `Instant?` | nullable until trip is completed |
| startLatitude | `Double?` | nullable until first location fix succeeds |
| startLongitude | `Double?` | nullable until first location fix succeeds |
| endLatitude | `Double?` | nullable until trip is completed |
| endLongitude | `Double?` | nullable until trip is completed |
| distanceMeters | `Double` | sum of accepted LocationSample segment distances |
| category | `String` | one of BUSINESS, PERSONAL, MEDICAL, CHARITY, UNCLASSIFIED |
| note | `String` | freeform user note, empty string by default |
| status | `String` | one of CANDIDATE, IN_PROGRESS, COMPLETED, DISCARDED |
| detectionSource | `String` | one of ACTIVITY_TRANSITION, GEOFENCE_EXIT |
| createdAt | `Instant` | UTC timestamp |
| updatedAt | `Instant` | UTC timestamp |

### LocationSample (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| tripId | `Long` | foreign key to Trip.id, cascade delete |
| latitude | `Double` |  |
| longitude | `Double` |  |
| accuracyMeters | `Float` | discard samples above 100m accuracy for distance calculation |
| capturedAt | `Instant` | UTC timestamp |

### GeofenceAnchor (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key; use fixed value 1 for current parked-location anchor |
| latitude | `Double` |  |
| longitude | `Double` |  |
| radiusMeters | `Float` | default 150m |
| updatedAt | `Instant` | UTC timestamp |

### Entitlement (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; use fixed value default |
| isSubscribed | `Boolean` | cached RevenueCat entitlement state |
| productId | `String?` | nullable until purchased |
| expiresAt | `Instant?` | nullable when inactive or unknown |
| lastCheckedAt | `Instant` | UTC timestamp |

### AppSetting (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| key | `String` | primary key; supported keys: onboardingComplete, trackingEnabled |
| value | `String` | store booleans as true or false |

## Features

### Battery-efficient automatic trip detection

Detect driving and record mileage without keeping GPS active all day.

- **Answers complaint:** Severe battery drain

- **Screens:** Onboarding, Dashboard, Settings

- **Estimated hours:** 80

**Implementation notes:** Declare and request precise location, notification, and Android activity-recognition runtime permission. Register ActivityRecognitionClient.requestActivityTransitionUpdates for IN_VEHICLE ENTER and EXIT using a BroadcastReceiver PendingIntent. While idle, do not call requestLocationUpdates. On IN_VEHICLE ENTER, first check entitlement/free-limit state; if tracking is allowed, create a Trip with status IN_PROGRESS, call FusedLocationProviderClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY), store the start sample if accuracy <=100m, then start a foreground tracking service with an ongoing notification. Only inside that foreground service request location updates with LocationRequest.Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, 60000).setMinUpdateDistanceMeters(100f).setMinUpdateIntervalMillis(30000).build(). For every accepted sample, compute segment distance with the Haversine formula and add it to Trip.distanceMeters. On IN_VEHICLE EXIT, start a 3-minute debounce timer; if no IN_VEHICLE ENTER arrives before the timer ends, remove location updates, set endedAt/end coordinates/status COMPLETED, and create/update a 150m geofence around the end point. On geofence EXIT, request one current location fix and wait for the next IN_VEHICLE ENTER instead of starting continuous GPS. If permissions are missing, keep detection disabled and show the missing-permission state on Dashboard.

**Acceptance criteria:**
- When the app is idle for 8 simulated hours with no IN_VEHICLE transition, the fake location client records zero requestLocationUpdates calls.
- An IN_VEHICLE ENTER transition creates exactly one IN_PROGRESS trip when the user is under the free limit or subscribed.
- During an active trip, location updates are requested with interval 60000ms and minimum distance 100m.
- An IN_VEHICLE EXIT transition followed by 3 minutes without re-entry completes the trip and removes location updates.
- Samples with accuracy greater than 100m are stored neither as distance-contributing samples nor as start/end coordinates.

### Trip review and categorization

Let users review detected trips and classify them for mileage records.

- **Answers complaint:** baseline parity

- **Screens:** Dashboard, TripDetail

- **Estimated hours:** 40

**Implementation notes:** Dashboard observes Room Flow<List<Trip>> ordered by startedAt DESC and renders recent non-DISCARDED trips. TripDetail loads a single Trip by route tripId. Category selector writes one of BUSINESS, PERSONAL, MEDICAL, CHARITY, or UNCLASSIFIED to Trip.category. Save updates category, note, and updatedAt in one Room transaction. Discard sets status DISCARDED instead of deleting, so reports can exclude it and tests can verify the action without orphaning samples.

**Acceptance criteria:**
- A completed trip appears on Dashboard with date, miles rounded to one decimal, and current category.
- Changing a trip from UNCLASSIFIED to BUSINESS and tapping Save persists after process recreation.
- Discarding a trip removes it from Dashboard and excludes it from report summaries.
- Opening a missing tripId route shows the trip not found state instead of crashing.

### IRS-style PDF and Excel exports

Generate mileage-log reports in PDF and XLSX formats for a selected date range.

- **Answers complaint:** baseline parity

- **Screens:** Reports

- **Estimated hours:** 40

**Implementation notes:** Reports defaults the date range to the current calendar month in the device time zone. Query COMPLETED trips whose startedAt is inside the range and whose category matches selected filters. Summary total miles is distanceMeters / 1609.344 rounded to one decimal for display. PDF export uses android.graphics.pdf.PdfDocument, one table row per trip with columns Date, Start, End, Category, Purpose/Note, Miles; write to an OutputStream returned by ACTION_CREATE_DOCUMENT with MIME application/pdf. Excel export uses org.dhatim.fastexcel.Workbook and creates a sheet named Mileage Log with the same columns plus a final Total row; write to ACTION_CREATE_DOCUMENT with MIME application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. Do not request broad storage permissions; all file writes go through the Storage Access Framework URI chosen by the user.

**Acceptance criteria:**
- With two completed BUSINESS trips in the selected month, Reports shows trip count 2 and total miles equal to the sum of both trips rounded to one decimal.
- PDF export creates a non-empty PDF containing the visible date range, both trip rows, and a Total row.
- Excel export creates a non-empty XLSX file containing a sheet named Mileage Log and the columns Date, Start, End, Category, Purpose/Note, Miles.
- Discarded trips and trips outside the selected date range are absent from both exports.

### Fair-price subscription billing

Sell the app at the report-supported fair price of $2.50/month instead of $10-20/month.

- **Answers complaint:** Price vs named alternatives

- **Screens:** Paywall, Dashboard, Settings

- **Estimated hours:** 24

**Implementation notes:** Use RevenueCat Purchases SDK with a Google Play subscription product id roadwise_monthly_250 and entitlement id pro. Paywall fetches Offerings.current and displays the monthly package price returned by RevenueCat; seeded development configuration must use $2.50/month. Subscribe calls Purchases.sharedInstance.purchaseWith and, on success, stores Entitlement(id='default', isSubscribed=true, productId, expiresAt, lastCheckedAt). Restore purchases calls restorePurchases and updates the same cached Entitlement. If RevenueCat is unavailable, show purchase error and keep the local entitlement unchanged.

**Acceptance criteria:**
- Paywall displays $2.50/month when the RevenueCat offering returns a $2.50 monthly package.
- No Paywall copy displays $10/month, $20/month, or $130/year as this app's price.
- A successful purchase sets isSubscribed to true and removes free-limit blocking on Dashboard.
- Restore purchases updates Entitlement from RevenueCat customer info without creating a local account.

### Upfront free-trip limit visibility

Show the monthly free trip limit before and during use so users are not surprised mid-month.

- **Answers complaint:** Free tier too small

- **Screens:** Onboarding, Dashboard, Paywall, Settings

- **Estimated hours:** 8

**Implementation notes:** Set FREE_TRIP_LIMIT_PER_MONTH = 15 because the complaint names the incumbent's hidden 15 trips/month limit; the v1 fix is visibility and predictable blocking. Onboarding must show '15 free trips per month' before permission requests complete. Dashboard computes current-month completed trip count from Room using the device time zone and always displays '{count} of 15 free trips used this month' for unsubscribed users. When count >=15 and Entitlement.isSubscribed is false, automatic trip creation is blocked before creating a Trip row, Dashboard status becomes Free limit reached, and the primary action opens Paywall. Subscribed users do not see blocking, but Settings still shows subscription state.

**Acceptance criteria:**
- Onboarding visibly contains the text '15 free trips per month'.
- An unsubscribed user with 14 completed trips sees '14 of 15 free trips used this month'.
- An unsubscribed user with 15 completed trips cannot create a new automatic trip from an IN_VEHICLE ENTER event.
- A subscribed user with 15 completed trips can still create a new automatic trip.

### Core navigation and local-first app shell

Provide the Compose navigation, Room wiring, and local state needed for the mileage tracker to be usable without a backend.

- **Answers complaint:** baseline parity

- **Screens:** Onboarding, Dashboard, TripDetail, Reports, Paywall, Settings

- **Estimated hours:** 8

**Implementation notes:** Use a single MainActivity hosting NavHost with routes onboarding, dashboard, trip/{tripId}, reports, paywall, and settings. On launch, read AppSetting onboardingComplete; navigate to onboarding if false, otherwise dashboard. Use Room as the single source of truth and expose repository Flows to ViewModels. Bottom navigation is visible on Dashboard, Reports, and Settings only. All screens render explicit loading, empty, populated, and error states as listed in the screens section.

**Acceptance criteria:**
- Fresh install opens Onboarding.
- After onboardingComplete is set to true, app relaunch opens Dashboard.
- The NavHost contains exactly the six routes listed in the screens section.
- Dashboard, Reports, and Settings are reachable from bottom navigation.

## Store listing

- **Title:** RoadWise Mileage Log
- **Short description:** Battery-light mileage tracking with upfront free limits.
- **Category:** Maps & Navigation
- **Keywords:** mileage tracker, mileage log, trip tracker, business miles, IRS mileage, PDF mileage report, Excel mileage report, battery efficient GPS
- **Icon prompt:** Minimal Android app icon for a battery-efficient mileage tracker: centered simple car silhouette on a road line, deep teal background #1B6E5C, white car glyph, small subtle green route dot, flat vector style, rounded square adaptive icon, no text, no brand names.

**Long description:**

RoadWise Mileage Log tracks driving miles with a battery-conscious Android approach: activity recognition and geofencing start tracking only when driving is detected, instead of polling GPS all day. Review and categorize trips, see your free monthly trip usage upfront, and export IRS-style mileage logs as PDF or Excel files. Upgrade for $2.50/month when you need more than the visible free trip limit.

## Legal

- **Regulated category:** legal_evidence
- **Privacy policy URL:** https://roadwiselog.example.com/privacy (privacy claims verified: no)
- **Data collected:** precise location coordinates for trip start, end, and accepted route samples; trip timestamps; trip distance; trip category; trip notes entered by the user; subscription entitlement and purchase status

## Test plan

### 1. Severe battery drain (unit)

1. Create TripDetectionController with FakeActivityRecognitionClient, FakeFusedLocationClient, FakeTripDao, FakeEntitlementRepository set to subscribed=false and completedTripsThisMonth=0, and FakeClock set to 2026-01-15T08:00:00Z.
2. Call controller.onAppStarted().
3. Advance FakeClock by 8 hours without sending any activity transition.
4. Assert FakeFusedLocationClient.requestLocationUpdatesCallCount is 0.
5. Send controller.onActivityTransition(type=IN_VEHICLE, transition=ENTER, time=2026-01-15T16:00:00Z).
6. Assert FakeFusedLocationClient.getCurrentLocationCallCount is 1.
7. Assert FakeFusedLocationClient.lastLocationRequest.intervalMillis is 60000.
8. Assert FakeFusedLocationClient.lastLocationRequest.minUpdateDistanceMeters is 100.
9. Send controller.onActivityTransition(type=IN_VEHICLE, transition=EXIT, time=2026-01-15T16:20:00Z).
10. Advance FakeClock by 3 minutes and run pending timers.

**Expected:** No location polling occurs while idle; location updates start only after IN_VEHICLE ENTER and are removed after the debounced EXIT completes the trip.

### 2. Free tier too small (instrumented)

1. Install a fresh debug build and launch MainActivity.
2. On Onboarding, find text containing '15 free trips per month'.
3. Seed the Room database with 14 COMPLETED trips whose startedAt values are inside the current device calendar month.
4. Set Entitlement.isSubscribed=false.
5. Navigate to Dashboard.
6. Find text '14 of 15 free trips used this month'.
7. Insert one more COMPLETED trip in the current month and refresh Dashboard.

**Expected:** The free limit is visible on Onboarding and Dashboard, and Dashboard displays '15 of 15 free trips used this month' plus the Free limit reached state after the fifteenth trip.

### 3. Free tier too small (unit)

1. Create TripDetectionController with FakeEntitlementRepository set to isSubscribed=false and completedTripsThisMonth=15.
2. Send an IN_VEHICLE ENTER transition.
3. Query FakeTripDao.insertedTrips.
4. Set FakeEntitlementRepository to isSubscribed=true with completedTripsThisMonth still 15.
5. Send another IN_VEHICLE ENTER transition.

**Expected:** The unsubscribed transition inserts zero trips; the subscribed transition inserts one IN_PROGRESS trip.

### 4. Price vs named alternatives (instrumented)

1. Configure the debug RevenueCat fake offering to return one monthly package with displayed price '$2.50/month'.
2. Launch MainActivity, mark onboardingComplete=true, and open Paywall.
3. Wait until the offering loaded state is visible.
4. Search the Compose tree for '$2.50/month'.
5. Search the Compose tree for '$10/month', '$20/month', and '$130/year'.

**Expected:** Paywall shows '$2.50/month' and does not show '$10/month', '$20/month', or '$130/year' as this app's subscription price.

### 5. baseline parity (instrumented)

1. Seed Room with one COMPLETED trip with distanceMeters=1609.344, category=UNCLASSIFIED, and note=''.
2. Open Dashboard.
3. Tap the seeded trip card.
4. On TripDetail, select category BUSINESS and enter note 'Client visit'.
5. Tap Save.
6. Kill and recreate MainActivity.
7. Open the same TripDetail route.

**Expected:** TripDetail shows category BUSINESS and note 'Client visit' after recreation.

### 6. baseline parity (manual)

1. Seed or create two COMPLETED BUSINESS trips in the current month and one DISCARDED trip in the same month.
2. Open Reports.
3. Keep the default current-month date range and select only BUSINESS.
4. Tap Export PDF and choose a file named mileage-test.pdf in the system document picker.
5. Tap Export Excel and choose a file named mileage-test.xlsx in the system document picker.
6. Open mileage-test.pdf with the device PDF viewer.
7. Open mileage-test.xlsx with Google Sheets or Microsoft Excel.

**Expected:** Both files open successfully, contain exactly the two completed business trips, exclude the discarded trip, and show a Total row with the summed miles.

## Build instructions

```sh
chmod +x ./gradlew
./gradlew clean testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release-upload.jks -storepass changeit -keypass changeit -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=RoadWise Upload,O=Solo Builder,C=US"
ANDROID_SIGNING_STORE_FILE="$PWD/release-upload.jks" ANDROID_SIGNING_STORE_PASSWORD="changeit" ANDROID_SIGNING_KEY_ALIAS="upload" ANDROID_SIGNING_KEY_PASSWORD="changeit" ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
- `regulated_category_go_no_go`
