# PRD — flutter_app (Datafy Ai)

Evidence date: 2026-10-15. Source: `https://bitbucket.org/overdrive/flutter_app.git`, `develop` at `7d30344` (2026-04-13). Package `datafy_ai`, version `1.0.79+100103`.

There is no prior PRD in Silicon Ark. `README.md` is an operator changelog and setup guide, not a requirements spec. `pubspec.yaml` does not describe the product. Requirements below are taken from that README where it states behaviour, and from the code that implements behaviour. Provenance is marked on each item.

OVER-119's recorded tip `75ce743` is an ancestor, not `HEAD`. External files cited by the README (`../COMPREHENSIVE_SCOPE.md`, `../documentation/`) are not in the repository.

## Product purpose

Datafy Ai is the mobile client for retail store work. A person signs in, chooses a brand (customer), and then works that brand's stores: stock/availability tasks, promotions (specials), KaChing photo challenges, and surveys. Representatives can work offline. Admins use a parallel set of screens that talk to Supabase directly.

Provenance: **Inferred** from routes, schema, and the README changelog. No document in the repo states this as an approved purpose. **Requires confirmation.**

Android label and iOS display name: Datafy. MaterialApp title: Datafy Ai. Application id: `coza.datafy.web`.

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Rep | Signs in, picks a brand, sees linked stores, completes tasks, specials, KaChing, and surveys. PowerSync runs only for this role, and only after a brand is selected | `login_widget.dart`, `brands_widget.dart`, `POWERSYNC_OFFLINE_SYNC_RULES.md` | Implemented |
| Admin | Same work areas on `/…Admin` routes, online via Supabase. PowerSync is not started | `nav.dart` `requireAdmin`, `brands_widget.dart` | Implemented |
| Signed-out person | `/` shows Login. Other routes do not set `requireAuth` | `nav.dart` | Implemented |

`app_user.role` is read from Supabase. The client keeps only two values: if the trimmed role equals `admin` in any case, state becomes `Admin`; every other non-empty value, and a missing role, becomes `Rep`. A stored role such as manager is therefore treated as Rep.

Provenance: **Implemented** (`main.dart` `_refreshUserRoleFromDb`, login, brands). Whether other roles are supposed to exist is **Unknown / Requires Confirmation**.

`app_user` also has a `manager` text column. The client does not use that column as a login role. What it means in the database is **Unknown / Requires Confirmation**.

`sync_rules.yaml` syncs `app_user` only when `LOWER(role) = 'rep'`. Admin rows are not part of the Rep offline database. Admins read `app_user` through Supabase.

## Main workflows

### W1 — Sign in

Trigger: a person opens `/` while logged out, or `/login`.

Implemented behaviour (`login_widget.dart`):

- Default mode is phone. The typed number is converted by `phoneToEmail`: non-digits are stripped, and the result is `{digits}@datafy.co.za`. Sign-in is Supabase email/password with that address.
- A toggle switches to email sign-in. The typed email and the same password field are used.
- Both paths call `signInWithEmail`. There is no call to `signInWithApple` from the login page. The `sign_in_with_apple` package is a dependency. Whether Apple sign-in is required is **Unknown / Requires Confirmation**.
- After a successful sign-in, location permission is required. If it is missing, a dialog says "Location disabled" / "Please enable your location." and the app does not open Brands.
- If permission is granted and `checkGpsStatus` is false, the app opens location settings and does not open Brands.
- If permission is granted and GPS status is true, the app loads `app_user` by auth id, normalises the role, writes `userFullName`, logs `user_activity` type `login` with GPS, and opens Brands.
- On restart with an existing session, `main.dart` refreshes the role from `app_user` again. PowerSync is not started there.

Auth flow type: `AuthFlowType.implicit` (`lib/backend/supabase/supabase.dart`).

Locales: `en` and `af` (`main.dart`, iOS `CFBundleLocalizations`).

### W2 — Choose a brand

Trigger: a signed-in user lands on `/` or `/brands`.

Implemented behaviour (`brands_widget.dart`):

- The page refreshes `userRole` from `app_user` (any case of admin becomes `Admin`, otherwise `Rep`).
- Brand logos come from `customer_user` (Supabase on this page; the README says Rep multi-brand detection later uses PowerSync `customer_user`).
- Rep: PowerSync initialises when the user taps a brand, then the Rep dashboard opens. The README says the sync overlay is on the Rep dashboard, and the dashboard also initialises PowerSync if opened directly.
- Admin: PowerSync is not initialised. The Admin dashboard opens.
- A Rep who belongs to more than one brand sees "Change brand" on the profile. The README limits that card to Reps (`userBrandCount > 1`). Admin change-brand is described as not implemented.

Provenance of the tap and role split: **Implemented**. Provenance of the profile card: **Documented** (`README.md`).

### W3 — Work a store (Rep)

Trigger: a Rep opens a store from the store list (`/storePageMain`).

Store list rows come from `store_user_link` for that user, `active = 1` in the sync rules. The store page shows the list when PowerSync `app_user` is not synced yet, falling back to `FFAppState().userRole`.

From a store the Rep can open:

- Tasks (`/tasksList`, and SPAR variants `/tasksListSpar`, `/tasksListAll`, `/tasksListAllSpar`).
- Category pages (`/categoryPage`, SPAR `/categoryPageSpar`).
- Product overview (`/allItemsOverview`, SPAR `/allItemsOverviewSpar`) and store items (`/storeItems`).
- Promos / specials (`/promosList`, `/storePromos`, `/promosStoresList`, detail `/taskDetailsPromo` and in-store `/taskDetailsPromoInstore`).
- KaChing (`/kachingList`, `/kachingStoresList`, detail `/taskDetailsKachinga`). The path string ends in `a`. That spelling is what the route uses.
- Surveys (`/formList`, questions `/surveyQ1`).
- Task gallery (`/taskGallery`).
- Profile (`/profileNew`) and notifications (`/notifications`).

SPAR, SUPERSPAR, and KWIKSPAR take the SPAR screens in `store_items_widget.dart` and `r_f_g_categories_widget.dart`. `RFGCategoriesWidget` is not a named route.

Provenance: **Implemented**.

### W4 — Complete a task, special, or KaChing

Trigger: the user opens a detail screen and submits.

Documented in `README.md` and matched by helpers in `lib/flutter_flow/custom_functions.dart`:

- Completing without a real photo asks for confirmation. A placeholder image is not written. `hasUploadedImageUrl` rejects the FlutterFlow `R.png` URL and the Supabase placeholder `https://alb.datafyapp.co.za/storage/v1/object/public/uploads/R.png`.
- Success copy distinguishes an image upload ("Success!"), a save without an image ("Partial update."), and a thrown error ("Update failed! Please try again.").
- After success, navigation uses `pushNamed` to the list that matches the role. Admin must return to the Admin list. The Rep list is PowerSync-backed and is empty for an Admin.
- Admin "Fixed" sets `archived = 1`. Fixed means the chosen status is neither the "Not Fixed" label nor the "Incomplete" label (`isTaskStatusFixed`).
- Task, promo, and KaChing updates that go through the guarded helpers include `store_code` on the write. Detail screens pop when the row's `store_code` does not match the open store.
- Dates shown to the user use `dd-MM-yyyy HH:mm`, or `dd-MM-yyyy` when time is irrelevant (`FFAppConstants`).

Provenance: helpers and route split are **Implemented**. Dialog wording and the Admin archived flag are **Documented** in the README and cited against `task_details_admin` and `task_details_category_admin`, where `isTaskStatusFixed` is called.

Dashboard and notifications read tasks across the Rep's stores on purpose ("all my tasks"). They use `supabaseRowsToTasksAl`, which does not take a store code. Store-scoped lists use `supabaseRowsToTask`, which returns an empty list when `storeCode` is null or empty.

### W5 — Answer a survey

Trigger: the user opens `/formList` or `/formListAdmin` and starts a survey.

Documented behaviour (`README.md`, `form_list_widget.dart`, migrations):

- Store-linked surveys have a `store_code`. User-linked surveys have `store_code` null and a `user_id`, plus `region`, `channel`, and `customer_id`.
- A user-linked survey is shown in a store when region, channel, and customer match. A null channel on the link matches any channel (`form_list_widget.dart`).
- Question answers supported by the local schema: text, number, boolean, choice, multi-choice, and image (`survey_responses` columns). Image answers use `photo_id` and the PowerSync attachment queue.
- Activity types `survey_start` and `survey_submit` are named in the README.
- Sync includes store surveys only when `status = 'Incomplete'`, `archived = 0`, and `submitted` is false. User-linked rows sync when archived is 0 and (submitted is false or status is Incomplete).

Provenance: filter and schema **Implemented**. Activity type names **Documented**.

### W6 — Admin works online

Trigger: role is `Admin` and the user opens an admin route.

`requireAdmin` routes redirect anyone whose `FFAppState().userRole` is not exactly `Admin` to `/`. Rep routes do not set `requireAuth` or `requireAdmin`.

Admin lists read Supabase. The README says the manager product overview no longer applies `limit: 100` on `all_items_overview_admin`. Other admin lists are documented with initial limits (galleries and survey links 50, product store 100). Whether every admin query still has a limit was not re-counted in this pass.

PowerSync is not started for Admin, including when an Admin taps a brand.

### W7 — Offline sync (Rep only)

Trigger: a Rep taps a brand.

- Local schema is the JSON in `FFAppConstants.schema` (`lib/app_constants.dart`). PowerSync adds `id`. The schema must not declare `id`. `user_activity` does not declare `id` in that JSON.
- `attachments_queue` is `local_only`. The upload connector must not send it to Supabase. The README says that skip is in place.
- Sync rules in `sync_rules.yaml` define buckets `global`, `user_data`, `user_stores`, `user_store_data`, and `user_surveys`. `user_store_data` is one batch per store linked to the user, not per selected brand. The README warns this can be a million-plus operations for a Rep with many stores.
- Optional "Sync only on Wi-Fi" and a manual "Sync now" are documented on the Rep profile. The Wi-Fi flag was not re-read in this pass. Provenance: **Documented**.
- Logout clears app state and resets PowerSync (`README.md`: `clearOnLogout`, `resetPowerSync`). Provenance: **Documented**.

### W8 — Force update (inactive on the client)

`ForceUpdateService.check` reads Supabase `version.app_version`, retries three times, and fails open. Web skips the check. Android store URL uses `coza.datafy.web`. The iOS URL is `https://apps.apple.com/app/id0000000000` (placeholder id).

`main.dart` does not call the service or show `ForceUpdateOverlay`. The README says this is intentional.

The `develop` pipeline still PATCHes `version` where `id=1` after a Play upload. If the check is turned back on, that write becomes the minimum version. Provenance: **Implemented** pipeline, **inactive** client.

## Functional requirements

### FR-001 — Sign in with phone or email

The user can sign in with a mobile number or an email, plus a password.

- Actor: signed-out person.
- Trigger: Sign in on `/login`.
- Input: phone digits or email, password, location permission, GPS status.
- Behaviour: phone becomes `{digits}@datafy.co.za`. Supabase email/password auth. Brands opens only after location permission and a true GPS status check. Role is loaded from `app_user`.
- Output: session, `userRole`, `userFullName`, a `login` activity row.
- Errors: null user aborts. Missing location shows the location dialog. GPS off opens location settings.
- Evidence: `login_widget.dart`, `phoneToEmail` in `custom_functions.dart`.
- Implementation: Implemented.
- Confidence: High.

### FR-002 — Two roles only in the client

The client treats the user as Admin or Rep.

- Actor: signed-in user.
- Trigger: login, app restart, Brands load, brand tap.
- Input: `app_user.role`.
- Behaviour: case-insensitive `admin` becomes `Admin`. Any other value, including empty, becomes `Rep` at login (default `'Rep'`).
- Output: `FFAppState().userRole`.
- Evidence: `login_widget.dart`, `main.dart`, `brands_widget.dart`.
- Implementation: Implemented.
- Confidence: High.
- Requires confirmation: whether any non-admin role besides Rep is a real product role.

### FR-003 — Admin routes are blocked for non-admins

A Rep or unknown role cannot stay on an admin route.

- Actor: router.
- Trigger: navigation to a route with `requireAdmin: true`.
- Behaviour: if `userRole != 'Admin'`, redirect to `/`.
- Evidence: `FFRoute` in `nav.dart`. 22 admin routes set the flag, matching the admin page widgets.
- Implementation: Implemented.
- Confidence: High.
- Gap: `requireAuth` exists and defaults to false. No route sets it true. A logged-out deep link to a Rep route is not redirected by the router. Whether each page then fails closed is **Unknown / Requires Confirmation**.

### FR-004 — Rep sync starts after brand selection

PowerSync starts for a Rep when they select a brand, not at login.

- Actor: Rep.
- Trigger: brand tap; dashboard can start it if opened directly (documented).
- Behaviour: Admin never starts PowerSync.
- Evidence: `brands_widget.dart`, `main.dart` comment, `POWERSYNC_OFFLINE_SYNC_RULES.md`.
- Implementation: Implemented.
- Confidence: High.

### FR-005 — Store-scoped lists fail closed

A store task, promo, or KaChing list does not show another store's rows, and shows nothing when the store code is missing.

- Actor: Rep or Admin on a store-scoped list.
- Input: `storeCode`.
- Behaviour: `supabaseRowsToTask` returns `[]` when `storeCode` is null or empty, and skips rows whose `store_code` differs.
- The same fail-closed pattern is documented for promos, product images, store, galleries, and KaChing mappers.
- Dashboard "all my tasks" is the exception and is documented as intentional.
- Evidence: `custom_functions.dart` `supabaseRowsToTask`; README "Store data isolation".
- Implementation: Implemented for the task mapper. Other mappers: **Documented**, same file, not each re-quoted here.
- Confidence: High for tasks. Medium for every named mapper.

### FR-006 — Photo is optional and must be a real upload

Completing work without a photo does not store the placeholder image.

- Actor: Rep or Admin.
- Behaviour: placeholder URLs are not treated as uploads. README: a confirm dialog, then null image fields, and gallery inserts are skipped.
- Evidence: `hasUploadedImageUrl`, README.
- Implementation: helper Implemented. Dialog flow Documented.
- Confidence: Medium until each detail widget is re-traced.

### FR-007 — Surveys respect store and user scope

The form list shows store-linked surveys for the open store and user-linked surveys whose region, channel, and customer match that store.

- Actor: Rep (offline) or Admin (online).
- Evidence: `form_list_widget.dart`, migration `20250218000000_add_region_channel_to_survey_store_link.sql`, `sync_rules.yaml`.
- Implementation: Implemented.
- Confidence: High for the Rep filter and the sync rule. Admin query parity: **Requires confirmation** beyond the README.

### FR-008 — Release version is the pubspec patch

Shipping `develop` increments the pubspec patch, uploads an Android App Bundle to Play production, and can write that version into Supabase `version.app_version`.

- Actor: Bitbucket Pipelines.
- Evidence: `scripts/bump_version.sh`, `bitbucket-pipelines.yml`, `android/fastlane/Fastfile`.
- Implementation: Implemented.
- Conflict: README still says internal testing. See project plan discrepancies.
- The client does not currently force users onto that version (FR force-update is inactive).

### FR-009 — Languages

The app supports English and Afrikaans.

- Evidence: `supportedLocales` in `main.dart`, iOS Info.plist.
- Implementation: Implemented as locale registration. Translation coverage was not audited.
- Confidence: Medium.

## Integrations

| System | Purpose | Direction | Auth | Failure |
|---|---|---|---|---|
| Supabase Auth | Sign-in | Client to Supabase | Email/password. Anon key via `--dart-define` or the default in source | Sign-in returns null and the UI stops |
| Supabase PostgREST | Admin data and Rep pre-sync reads (`app_user`, `customer_user`, `version`) | Both | User JWT after login; pipeline uses the service role to PATCH `version` | Pipeline logs HTTP status and continues. Client force-update fails open. Other screens were not catalogued |
| Supabase Storage | Images. Bucket name constant `media`. Placeholder host `alb.datafyapp.co.za` | Both | Storage policies are not in this repo | README notes HTTP 406 if the bucket or RLS is wrong |
| PowerSync | Rep offline sync | Both | Supabase access token. `sub` is `user_id` | Cloud icon / empty categories. Documented in `POWERSYNC_OFFLINE_SYNC_RULES.md` |
| Google Play | Android release | CI to Play | Service-account JSON from Bitbucket | Upload fails the deploy step |
| App Store Connect / TestFlight | iOS | Custom pipeline | API key `.p8` via Bitbucket | Needs a macOS runner. Not on `develop` |
| SonarQube | Scan on `main` | CI to Sonar | `SONAR_TOKEN` | `develop` does not run this step |

Default Supabase URL: `https://datafy.appmonitor.co.za`. CI is expected to pass `SUPABASE_URL` and `SUPABASE_ANON_KEY`. The source default includes an anon key. That secret is not repeated here.

## Constraints

- Flutter SDK `>=3.0.0 <4.0.0`. Android `compileSdkVersion` and `targetSdkVersion` 35. `minSdkVersion` is `flutter.minSdkVersion` (the Flutter toolchain default, not a number in this file).
- Rep offline data is whatever `sync_rules.yaml` allows, and only if that file is deployed to the PowerSync project.
- Store isolation is a client filter plus SQL `WHERE store_code = :storeCode` on list pages (documented). It is not, by itself, a Supabase RLS policy. RLS was not in the three committed migrations.
- User-facing dates are `dd-MM-yyyy HH:mm`.
- iOS App Store id in the force-update service is a placeholder.

## Out of scope for this client

Creating the upstream task, promo, KaChing, and product files (upload tables such as `sjreport`, `promo_uploads`, and `kaching_uploads` exist as generated classes). Who loads those files is **Unknown / Requires Confirmation**. It is not a screen in the route list.

## Requires confirmation

- Approved product purpose and customer.
- Play track: README internal versus Fastfile production.
- Whether logged-out deep links to Rep routes must be rejected.
- Whether roles other than Admin and Rep exist.
- Whether the live PowerSync rules match `sync_rules.yaml`.
- Whether the committed anon key default is acceptable.
- Real iOS App Store id, if force update returns.
- Admin survey and list-limit parity with the Rep/offline rules.
