# App Manager

Workforce checklist management for small businesses. Owners write checklists,
apply them to their branches, and workers complete the tasks — optionally with
timers, locations, and photo proof.

- **Frontend** — Flutter (iOS, Android, macOS, web)
- **Backend** — PHP 8.2 + Apache, no framework
- **Database** — MariaDB 10.4

> Two people are building this from opposite ends — the owner side and the
> worker side — so this file is the handover between them. Read it before
> writing code, and update it in the same commit as the code you write. See
> [Keep this file current](#keep-this-file-current).

---

## Getting it running

You need [Docker Desktop](https://www.docker.com/products/docker-desktop/) and
the [Flutter SDK](https://docs.flutter.dev/get-started/install). No XAMPP, MAMP,
or local PHP/MySQL install.

```bash
git clone https://github.com/kero803/manager.git
cd manager
docker compose up -d
```

That starts Apache on **http://localhost:8888** and MariaDB alongside it. The
schema in `database/schema.sql` is applied automatically the first time the
database container starts, so there is nothing to import by hand.

Check it:

```bash
curl http://localhost:8888/manager/backend/
# {"status":"ok","message":"App Manager API is running"}
```

Then run the app:

```bash
cd frontend
flutter pub get
flutter run -d macos      # or: flutter run   (iOS simulator)
```

### Which device to use

`ApiConfig.baseUrl` in `frontend/lib/services/api_service.dart` is hardcoded to
`http://localhost:8888/manager/backend`.

- **macOS, iOS simulator, web** — `localhost` works as-is
- **Android emulator** — change it to `10.0.2.2`
- **Physical device** — use your computer's LAN IP

### Useful commands

```bash
docker compose logs -f web      # follow Apache and PHP errors
docker compose down             # stop, keeping the database
docker compose down -v          # stop and DELETE the database
docker compose up -d --build    # rebuild after changing the Dockerfile
```

PHP files are bind-mounted into the container, so edits apply immediately — no
rebuild needed. Only `Dockerfile` changes need `--build`.

### Inspecting the database

The MariaDB port is published on **3307** (not 3306, which a standalone MySQL
install often already owns). Connect with TablePlus, Sequel Ace, or similar:

| Field    | Value          |
| -------- | -------------- |
| Host     | `127.0.0.1`    |
| Port     | `3307`         |
| User     | `app_manager`  |
| Password | `app_manager`  |
| Database | `app_manager`  |

It is bound to `127.0.0.1`, so it is not reachable from the network.

### Resetting the database

The schema is applied only when the data volume is first created. To reapply it
after changing `database/schema.sql`:

```bash
docker compose down -v && docker compose up -d
```

### If you have an existing database from an older clone

A fresh clone needs nothing — `schema.sql` is current. An **existing** database
needs the migrations it has not seen yet, in order:

```bash
docker compose exec -T db mysql -uroot -proot < database/migrations/001_add_branches.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/002_phone_login.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/003_password_resets.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/004_messaging.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/005_message_photos.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/006_worker_checklists.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/007_alarms.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/008_worker_shifts.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/009_shift_break_window.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/010_checklist_order.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/011_location_pings.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/012_phone_change_verification.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/013_conversation_admins.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/014_branch_location.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/015_branch_subscriptions.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/016_manager_permissions.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/017_attendance_breaks.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/018_billing_enforcement.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/019_platform_admin.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/020_admin_controls.sql
docker compose exec -T db mysql -uroot -proot < database/migrations/021_rate_limits.sql
```

`021` adds `rate_limits`, a MariaDB-backed counter table for rate limiting
(this stack has no Redis) -- see `backend/config/rate_limit.php`.

`019` adds `users.is_admin` -- a platform-super-admin, DB-only (no UI grants
it). To make yourself an admin:
`UPDATE users SET is_admin = 1 WHERE id = <your user id>;`

`020` adds `platform_settings` (the billing-enforcement switch, now toggled
from the admin app instead of `.env`), `branches.admin_deactivated`, and
`users.blocked_at`.

`017` adds `attendance_breaks` and `shifts.break_duration_minutes` (a fixed-length
break alternative to the existing `break_start`/`break_end` clock-time window --
the two are mutually exclusive per shift).

`018` adds `branches.grace_period_ends_at`/`billing_due_day`, the date-tracking
for the real (still mock) paywall. Enforcement is off by default -- set
`BILLING_ENFORCED=true` in `backend/.env` to turn it on. See
`backend/config/billing.php` for the enforcement logic.

`002` replaces `users.email` with `users.phone`. Accounts that existed before it
get a placeholder number (`+90000000<id>`) rather than an invented plausible
one, so they are obviously not real. Development accounts are easiest to just
recreate.

---

## The data model

This is the part worth understanding before writing any code. The structure is
four levels deep and the middle one — the **branch** (Turkish: *şube*) — is what
most features hang off.

```
business  "Bon Suadiye"
  │
  ├─ branch (şube)  "Kadıköy"        ← own invite code, own staff, own work
  │    ├─ branch_members             who is staffed here
  │    ├─ locations                  spots inside it (kitchen, front desk)
  │    ├─ worker_groups              staffing groups, e.g. "Kitchen Staff"
  │    │    └─ worker_group_members
  │    └─ checklist_assignments      which templates apply here, see below
  │
  └─ branch (şube)  "Beşiktaş"
       └─ ...

checklists                            business-level TEMPLATES, not per-branch
  └─ tasks                            the steps: instructions, requires_photo,
                                        timer_minutes, sort_order

checklist_assignments                 a template applied to one branch
  └─ checklist_assignment_targets     narrows it to specific workers/groups;
                                        NO ROWS = everyone at the branch

task_completions                      one row per (task, branch, for_date):
                                        today's work state. No row = pending.
                                        Yesterday's rows just stop matching --
                                        that is the whole daily reset, see
                                        "How checklist work runs day to day"

conversations                         direct chats and named groups
  ├─ conversation_members             who is in it, and how far they have read
  └─ messages                         text, a photo, or both -- never neither
```

### Rules that are easy to get wrong

**Accounts are keyed by phone number.** `users` has no email column. The
signup and login screens pair a country-code selector (defaulting to +90) with
a digits-only field; see `widgets/app_phone_field.dart`. The field groups
digits as they are typed (`532 123 45 67`) but those spaces are **display
only** — send `CountryCode.digitsOf(controller.text)`, never the raw text.

**Roles live on `business_members`, never on `users`.** The same account can own
one business and work at another. `Membership` (business + role) is the unit,
not the user.

**Invite codes belong to branches, not businesses.** A worker who enters a code
joins that specific branch and, through it, the business. `businesses` has no
`join_code` column — it was removed.

**A worker can belong to several branches.** `branch_members` is a separate
table for exactly this reason. Joining a second branch of the same business
does not duplicate the business membership.

**Checklists are business-level templates.** A checklist becomes live work in a
branch through `checklist_assignments`. Editing a template changes it for every
branch using it — that is intentional.

**Deleting the last branch deletes the business.** A business with no branch has
no working screens, so the API does this deliberately and warns about it first.

**`users.last_business_id`** decides which business opens on login.
Client-side, `active_branch_id` in SharedPreferences decides which branch.

---

## How the app is organised

### Owners and managers

A bottom tab shell (`owner_home_screen.dart`) over five tabs, Messages sitting
in the middle where a thumb reaches most easily. **Everything except Messages
and Settings is scoped to one branch at a time** — the branch you are "in".

| Tab            | File                        | What it shows                                                       |
| -------------- | --------------------------- | --------------------------------------------------------------------- |
| **Home**       | `owner/home_tab.dart`       | Active branch name (tap to switch), a real `percent_done_today` (tap for today's checklists, read-only), its team, its checklists |
| **Checklists** | `owner/checklists_tab.dart` | Every template in the business, with task and branch counts         |
| **Messages**   | `messaging/messages_tab.dart` | Conversations, most recent first, with unread badges              |
| **Team**       | `owner/team_tab.dart`       | People grouped by role; tap one to open their profile               |
| **Settings**   | `owner/settings_tab.dart`   | Businesses, branches, language, logout                              |

Branches are managed from **Settings**, not a tab of their own: you set them up
once and then work inside one.

### Workers

A bottom tab shell (`worker_home_screen.dart`) over four tabs: **Home**, the
branch they work at and today's checklists — tap a task to complete it, with
a photo or a countdown timer if the task calls for one (see "How checklist
work runs day to day"); **Messages**, identical to the owner's; **Team**,
everyone else at their branch; **Settings**, business/branch name, language,
logout.

A worker can be staffed at several branches; nothing switches between them yet
in this shell (the owner side has this via `_BranchSwitcherSheet` in
`home_tab.dart` — the same pattern applies here when needed).

---

## API reference

Every endpoint is a plain PHP file addressed by path. There is no router.
All requests except register/login need `Authorization: Bearer <token>`.

Header lookup is **case-insensitive** (`get_header()` in
`backend/config/helpers.php`) because Dart's `http` package sends
`authorization` lowercase while curl sends `Authorization`. Do not revert that.

### Auth

| Method | Path                     | Notes                                              |
| ------ | ------------------------ | -------------------------------------------------- |
| POST   | `/api/auth/register.php` | `{name, country_code, phone, password}` → token. 409 if number taken |
| POST   | `/api/auth/login.php`    | `{country_code, phone, password}` → token, user, memberships |
| POST   | `/api/auth/request_reset.php`  | `{country_code, phone}` → issues a verification code |
| POST   | `/api/auth/verify_reset.php`   | `{country_code, phone, code}` → single-use reset token |
| POST   | `/api/auth/reset_password.php` | `{reset_token, password}` → sets the new password |

**Accounts are identified by phone number, not email.** Not everyone running a
small business has an email address. The client sends the dialling code and the
typed digits separately; `normalize_phone()` in `config/helpers.php` strips
punctuation, a leading trunk zero and a duplicated country code, storing one
canonical form (`+905321234567`). So "0532 123 45 67", "+90 532 123 4567" and
"05321234567" all reach the same account. Always normalise before comparing.

### ⚠️ Password reset uses a placeholder code

There is no SMS provider, so `send_reset_code()` in `config/helpers.php`
returns the fixed code **`000000`**, and `request_reset.php` sends it back in
the response for the app to display. **Anyone who knows a phone number can
therefore reset that account.** That is fine on a development machine and
unacceptable anywhere real.

To make it real, change `send_reset_code()` to generate six random digits, hand
them to an SMS API, and return null so nothing is echoed to the client. Then
drop `placeholder_code` and `placeholder` from the `request_reset.php`
response — the app hides its on-screen code notice when they are absent, so no
UI change is needed. Everything else already treats the code as a secret: it is
stored hashed, expires after 15 minutes, and dies after 5 wrong attempts.

Resetting deletes the account's `auth_tokens`, logging it out everywhere.
Someone recovering a stolen account should not leave the thief signed in.

Login returns **422** with a `code` of `unknown_phone` or `wrong_password` —
deliberately *not* 401, so a failed sign-in is never mistaken for an expired
session. A 401 means only one thing: the stored token is bad.

### Businesses

| Method | Path                            | Notes                                                  |
| ------ | ------------------------------- | ------------------------------------------------------ |
| POST   | `/api/businesses/create.php`    | Creates business + its first branch, caller becomes owner |
| POST   | `/api/businesses/join.php`      | `{join_code}` — joins a **branch** and its business     |
| GET    | `/api/businesses/overview.php`  | The big one, see below                                  |
| POST   | `/api/businesses/switch.php`    | `{business_id}` — sets `last_business_id`               |
| POST   | `/api/businesses/delete.php`    | Owners only. Two-phase, see below                       |

**`overview.php` is the endpoint the whole owner shell runs on.** Optional
`?branch_id=N` picks the active branch; without it you get the first one. It
returns, all scoped to that branch:

```jsonc
{
  "business":   { "id": 1, "name": "Bon Suadiye" },
  "businesses": [ /* every business the user belongs to, for the switcher */ ],
  "branch":     { "id": 1, "name": "Kadıköy", "join_code": "AB12CD34" },
  "branches":   [ /* every branch in this business */ ],
  "role":       "owner",
  "members":    [ /* people staffed at THIS branch */ ],
  "checklists": [ /* templates applied to THIS branch */ ],
  "stats":      { "workers": 4, "checklists": 3, "percent_done_today": 33 }
}
```

`join_code` is `null` for workers — it is an invite credential.

`percent_done_today` is the real completion rate across every task in every
checklist applied to this branch **today**, regardless of who it is targeted
at — this is the owner's "how is today going" number, not a per-viewer one.
It is **null**, not 0, when no checklist has any tasks applied here yet, so
the UI can show a dash rather than a fabricated 0%. Computed inline in
`overview.php` from `task_completions`; see "How checklist work runs day to
day" for how that table works.

### Branches

| Method | Path                        | Notes                                              |
| ------ | --------------------------- | -------------------------------------------------- |
| GET    | `/api/branches/index.php`   | All branches with worker/checklist counts          |
| POST   | `/api/branches/index.php`   | `{name, address?}` — owners/managers               |
| GET    | `/api/branches/show.php?id=` | One branch: invite code, staff, applied checklists |
| POST   | `/api/branches/delete.php`  | Owners only. Two-phase, see below                  |

### Checklists (templates)

| Method | Path                                    | Notes                                       |
| ------ | --------------------------------------- | ------------------------------------------- |
| GET    | `/api/checklists/index.php`             | All templates. `?branch_id=N` filters       |
| GET    | `/api/checklists/show.php?id=`          | Tasks + which branches/targets it applies to |
| POST   | `/api/checklists/create.php`            | Title, tasks and branch assignments at once |
| POST   | `/api/checklists/apply.php`             | Sets branches AND per-branch targeting, see below |
| GET    | `/api/checklists/today.php`             | The caller's checklists for today, with progress — see "How checklist work runs day to day" |

`create.php` writes the checklist, its tasks and its branch assignments in **one
transaction** — three round trips would strand a checklist with no tasks if a
later call failed. Blank task rows are dropped rather than rejected.

`apply.php`'s shape is `{checklist_id, branches: [{branch_id, target_user_ids?,
target_group_ids?}]}` — a **list of branch objects**, not a flat list of ids,
because targeting is per-branch: a staffing group only exists at one branch,
and "this worker" is meaningless at a branch they are not staffed at. It takes
the **whole** set for both branches and targets, not add/remove deltas: a
branch absent from the list is unapplied, and a branch present with empty
target arrays is visible to everyone staffed there.

### Tasks

| Method | Path                        | Notes                                          |
| ------ | --------------------------- | ----------------------------------------------- |
| POST   | `/api/tasks/create.php`     | Adds one task to an existing checklist, at the end |
| POST   | `/api/tasks/update.php`     | Edits title/instructions/requires_photo/timer_minutes; only fields present in the body change |
| POST   | `/api/tasks/delete.php`     | Cascades to that task's `task_completions` history |
| POST   | `/api/tasks/reorder.php`    | `{checklist_id, task_ids}` — sets `sort_order` from array position. Must include every task the checklist currently has |
| POST   | `/api/tasks/complete.php`   | Completes, or (`undo: true`) undoes, one task for today — see below |

`complete.php` is multipart when a photo is attached, matching the messaging
photo convention and task photo proof's storage layout
(`uploads/checklist_photos/`). The **unique key on
`(task_id, branch_id, for_date)`** is what actually resolves two people
tapping the same task at once — the loser's insert fails and the endpoint
answers `already_completed` (409) rather than creating a duplicate. Undo only
removes a completion made by the caller.

### Staffing groups

| Method | Path                       | Notes                                          |
| ------ | -------------------------- | ----------------------------------------------- |
| GET    | `/api/groups/index.php?branch_id=`  | Groups at a branch, each with its members |
| POST   | `/api/groups/index.php`    | `{branch_id, name, user_ids[]}`                |
| POST   | `/api/groups/delete.php`   | Cascades to `checklist_assignment_targets` — a checklist targeted only at this group becomes branch-wide again, not invisible to everyone |

Separate from messaging groups (`conversations` with `type = 'group'`) on
purpose: adding someone to a chat should never accidentally assign them work.

### Messaging

| Method | Path                              | Notes                                              |
| ------ | ---------------------------------- | -------------------------------------------------- |
| GET    | `/api/conversations/index.php`     | The caller's threads, most recent first, with unread counts |
| POST   | `/api/conversations/open.php`      | `{user_id}` → the direct thread with them, creating it only if there is none. `{title, user_ids[]}` → a new group |
| GET    | `/api/conversations/messages.php?id=`  | Messages in a thread. `?after_id=N` for polling |
| POST   | `/api/conversations/messages.php`  | `{conversation_id, body}` (JSON) or multipart with a `photo` field, or both |

**Direct threads are idempotent; groups are not.** Opening a direct chat with
the same person twice always resolves to the same conversation — messaging
someone never splits their history across two threads. Creating a group always
makes a new one, since two groups may legitimately share a name.

**Read receipts** come from `conversation_members.last_read_message_id`, one
row per person per thread. GET marks the thread read up to whatever it
returns, so reading and fetching are the same action — there is no separate
"mark as read" call. A message is `"read": true` once every *other* member's
read point has passed it: in a direct chat that is the one other person; in a
group it is whoever is slowest, which is also why a large group's ticks lag
behind a two-person chat's.

That also means a fast poll (`after_id=N`) can never *unread* something: it
only reports new messages. A ticks staying grey after the other person has
actually opened the chat is not a bug in the fast path — the client
periodically does a full re-fetch (`after_id` omitted) specifically to catch a
read-status change on a message already on screen. See the polling comment in
`chat_screen.dart`.

**Photos are multipart, matching task photo proof's convention exactly**:
`uploads/message_photos/` on disk, a `photo_path` column holding the relative
path, resolved against `ApiConfig.baseUrl` by the client. A message is text, a
photo, or both — `chk_message_has_content` (migration `005`) makes an empty
message impossible at the database level, not just in the app.

### The two-phase delete pattern

Both delete endpoints answer twice. Called **without** `confirm`, they report
what would be destroyed and change nothing:

```jsonc
POST /api/branches/delete.php  { "branch_id": 3 }
{ "preview": true, "deletes_business": false, "member_count": 4, "checklist_count": 2 }
```

The app puts those real counts in the dialog, so the user agrees to "4 people's
placements and 2 applied checklists" rather than a vague warning. Only a second
call with `"confirm": true` deletes anything. Reuse this pattern for anything
else destructive.

### Error codes

Failures carry a stable `code` alongside the English `error` message. The app
maps codes to translated strings in `frontend/lib/services/error_messages.dart`
— screens must never show a raw server message, or Turkish users see English.

Existing codes: `unknown_phone`, `wrong_password`, `missing_credentials`,
`phone_taken`, `invalid_phone`, `password_too_short`, `missing_fields`,
`invalid_join_code`, `already_in_branch`, `no_tasks`, `not_found`,
`server_error`. Add a new one in both `.arb` files and in `errorMessage()`.

---

## What is built

- Signup, login, logout, session persistence — by phone number, not email
- Password reset by phone, in three steps — **with a placeholder code, see
  the warning in the API reference**
- Create a business (auto-creates its first branch) / join a branch by code
- Owner shell: Home, Checklists, Team, Settings — all branch-scoped
- Branch switcher on Home; branch create/delete in Settings
- Multiple businesses per account: list, switch, add another, delete
- Checklists: create with tasks, edit a task's title/instructions/photo
  requirement/timer, add/reorder/delete tasks, and target a template at a
  branch (everyone there, specific people, or a staffing group)
- Staffing groups (`owner/groups_screen.dart`, `worker_groups` table): who
  does what work, kept separate from messaging groups on purpose
- Daily checklist work: a worker's Home shows today's checklists with live
  progress, completes tasks (with a required photo or a countdown timer when
  the task calls for one), and the reset back to pending every morning needs
  no cron job — see "How checklist work runs day to day"
- Owner oversight of the same data: a real `percent_done_today` on Home,
  tapping it opens the same checklist list read-only
- Messaging: direct chats and named groups, read receipts (ticks), text and
  photo messages (camera or gallery), polling — both owner and worker sides.
  No push: messages appear once the app is open, not while it is closed
- Localization in English and Turkish across every screen
- Errors that say what actually went wrong, translated

## What is not built

Ordered by what unblocks the most:

1. **Branch/business names cannot be renamed.**
2. **Team management** — no promoting a worker to manager, no removing someone
   from a branch or business.
3. **Calling** — the call buttons on a chat and a profile are shown but say
   "not available yet" on purpose. No telephony integration exists.
4. **Online / last-seen status** — ticks show read receipts; there is no
   presence system. Needs a heartbeat from the client and a status column,
   deliberately left for later rather than folded into ticks.
5. **Push notifications** — needs Firebase Cloud Messaging and, for iOS, APNs
   certificates from an Apple Developer account. Cannot be set up or tested
   from a simulator; someone with the account has to do this.
6. **Locations** — the table and the `tasks.location_id` column exist; tasks
   cannot be assigned to one, and there is no UI for managing locations.
7. **Explicit per-worker task assignment** — targeting narrows a *checklist*
   to specific people or a group, but within an applied checklist any assigned
   worker can complete any task ("who does the whole opening routine today"),
   not "this specific task is Ali's job" at the individual-task level. Worth
   revisiting only if the shared-checklist model turns out not to fit some
   businesses.

---

## Keep this file current

**Two people are building this app from opposite ends, and this file is how
each side learns what the other did.** If you add a feature and do not write it
down here, the next person re-derives it from the code, or worse, builds a
second version of it.

So whenever you finish something, update this README in the same commit:

- **Added an endpoint?** Put it in the API reference table with its request
  shape, and say anything surprising about what it returns.
- **Changed the schema?** Update the data model diagram, add a migration under
  `database/migrations/` numbered in sequence, and mention it in the migration
  list under "Getting it running". Never edit an existing migration — someone
  else has already run it.
- **Built a screen?** Add it to "How the app is organised".
- **Finished something from "What is not built"?** Move it up to "What is
  built". If you only did half of it, say which half.
- **Hit a trap that cost you an hour?** Write it into "Conventions". Half that
  section exists because someone lost time to it — plural argument ordering,
  `setState` arrow bodies, mysqli throwing.
- **Made a decision the code cannot explain?** Say why here. The reasoning
  behind branch-scoped invite codes or checklists-as-templates is not
  recoverable from reading the queries.

Two rules that keep this file trustworthy:

**Do not describe what you have not built.** An empty section is honest; a
section describing a screen that does not exist sends the other side chasing
it. Write it when it works, not when it is planned.

**Do not invent numbers or behaviour.** `percent_done_today` is real now, but
still returns null rather than 0 when there is no applied work to measure, and
the UI shows a dash for exactly that reason. If something is not real yet, say
so plainly here and in the UI rather than showing a plausible-looking number.

If you change something that breaks the other side's work — a response shape,
a column, an error code — say so at the top of your commit message as well as
here, so it is visible in `git log` and not only in a diff.

---

## How checklist work runs day to day

This is the piece that took the longest to get right, so it is worth
understanding before touching any of `checklists/today.php`,
`tasks/complete.php`, or the `task_completions` table.

### The daily reset has no cron job

`task_assignments` (the old table) had no date — one row per (task, worker)
forever, which cannot express "fresh every morning". It is gone, replaced by
**`task_completions`**, keyed on `(task_id, branch_id, for_date)`.

A task's state today is just: does a `task_completions` row exist for
`for_date = CURDATE()`? No row means pending. Yesterday's rows simply do not
match today's date filter — nothing has to reset them, because "reset" was
never a real operation, just the natural consequence of filtering by date.
Completion history is not deleted; querying a past `for_date` still shows it.

### Completing a task is shared, not personal

You chose **one shared checklist per branch per day**: whoever is assigned can
complete any task, and it is done for everyone the moment anyone does it. Two
people tapping the same task within the same second both send the same
request; the **unique key on `(task_id, branch_id, for_date)`** is what
actually decides the winner — the loser's insert fails and
`tasks/complete.php` turns that into a clean `already_completed` (409) rather
than a duplicate row. `worker_id` on the row records who did it, the same way
`sender_id` on a message records who sent it, without making the work
personal.

Undo (`{..., "undo": true}` to the same endpoint) only removes a completion
made by the caller — a shared checklist should not let one worker silently
reopen another's completed task.

### Targeting: who a checklist is *for*, not who can *see* it

A checklist applied to a branch (`checklist_assignments`) is visible to
everyone staffed there by default. `checklist_assignment_targets` narrows
that to specific workers or specific **staffing groups**
(`worker_groups` — separate from messaging groups, since adding someone to a
chat should never accidentally assign them work). No target rows means
branch-wide; that is why the table is additive rather than a column.

**Owners and managers bypass targeting entirely** in `checklists/today.php`
— they see every checklist applied to the branch regardless of who it is
aimed at. Targeting narrows who the *work* is for, not who is allowed
oversight; an owner should never lose visibility by not being in the right
group. This one line in `today.php` is easy to regress if the query is
rewritten, so if "owner can't see a checklist a worker can" ever comes back
as a bug, that bypass is the first thing to check.

### Where each piece lives

- `checklists/today.php` — the whole "what do I need to do" screen in one
  call: every visible checklist, its tasks, and today's completion state
- `tasks/complete.php` — completes or (`undo: true`) undoes one task; the
  same file handles both so "what authorises touching today's row" stays in
  one place. Multipart when a photo is attached, matching messaging's photo
  convention and task photo proof's storage layout
  (`uploads/checklist_photos/`)
- `frontend/lib/screens/worker/today_checklists_view.dart` — the checklist
  list, shared verbatim between the worker's Home tab (`canComplete: true`)
  and the owner's oversight screen (`canComplete: false`) reached by tapping
  the "today" stat card on owner Home
- `frontend/lib/screens/worker/complete_task_screen.dart` — instructions, a
  countdown timer if `timer_minutes` is set, photo capture, and the
  complete/undo action
- `frontend/lib/screens/owner/checklist_targeting_screen.dart` — per-branch
  targeting UI: everyone / specific people / specific groups, with group
  creation inline

---

## Conventions

Follow these; the codebase is consistent about them.

**All user-facing strings go through l10n.** Add to both
`frontend/lib/l10n/app_en.arb` and `app_tr.arb`, then:

```bash
cd frontend && flutter gen-l10n
```

Generated `app_localizations*.dart` files are gitignored. Note that `gen-l10n`
orders plural parameters **alphabetically**, not in the order they appear in
the string — check the generated signature before calling, or your numbers land
in the wrong slots.

**Never show a raw exception.** Use `errorMessage(e, l10n)` from
`services/error_messages.dart`.

**Phone fields send digits, not what is on screen.** `AppPhoneField` shows
`532 123 45 67` but must submit `5321234567` via `CountryCode.digitsOf()`, with
the dialling code as a separate `country_code` field. Completeness is checked
client-side with `country.isComplete(text)` — +90 and +1 require exactly 10
digits — so a half-typed number is answered instantly instead of round-tripping.
Note that a `LengthLimitingTextInputFormatter` cannot cap this: it counts
characters, and the display spaces make that number wrong.

**Disable autocorrect on every text input.** Turkish words are not in the
English dictionary and smart quotes corrupt pasted text. `AppTextField` handles
this; a raw `TextField` needs `autocorrect`, `enableSuggestions`,
`smartDashesType` and `smartQuotesType` all disabled.

**`setState` needs braces, not an arrow**, when assigning a Future:
`setState(() { _future = f; })`. An arrow body returns the assigned value, and
returning a Future from `setState` is a runtime error.

**mysqli throws.** `if (!$stmt->execute())` never catches anything — wrap in
`try/catch (mysqli_sql_exception)` and check `$conn->errno === 1062` for
duplicates.

**Validate ownership server-side.** Every endpoint that takes an id must scope
it to the caller's business, so a guessed id from another business reads as 404.
`require_membership()`, `require_role()` and `require_branch()` do this.

**`ApiService` caches token, user and active branch in static fields** to avoid
a SharedPreferences race after login. Static fields survive hot reload — after
changing them use a full restart (`q`, then `flutter run`), not `r`.

---

## Project layout

```
backend/
  api/
    auth/           register, login
    businesses/     create, join, overview, switch, delete
    branches/       index (list + create), show, delete
    checklists/     index (list), show, create
    tasks/          legacy single-task create
    assignments/    worker status + photo updates
  config/           database connection, env loading, shared helpers
database/
  schema.sql        full schema, applied on first container start
  migrations/       incremental changes for existing databases
frontend/
  lib/
    l10n/           English and Turkish .arb sources
    screens/        one file per screen; owner tabs under screens/owner/
    services/       API client, error mapping, session routing, locale
    theme/          design tokens
    widgets/        shared UI pieces
```

## Design spec

35 screens live in a Claude Design project (`App Manager.dc.html`), reachable
through the DesignSync MCP tool. Tokens are already in
`frontend/lib/theme/app_theme.dart`:

| Token   | Value     |             | Token    | Value     |
| ------- | --------- | ----------- | -------- | --------- |
| primary | `#185FA5` |             | success  | `#1E8E5A` |
| ink     | `#1A1A18` |             | warning  | `#C77800` |
| muted   | `#6B6A64` |             | danger   | `#C0392B` |
| surface | `#F7F7F4` |             | border   | `#E5E4DF` |

Inputs 50px, buttons 52px, radius 12.

---

## Testing the API by hand

Get a token, then use it. Note the **lowercase** `authorization` header — that
is what the Dart client sends, so test with it:

```bash
B=http://localhost:8888/manager/backend

TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"country_code":"+90","phone":"5321234567","password":"secret123"}' \
  "$B/api/auth/login.php" | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')

curl -s -H "authorization: Bearer $TOKEN" "$B/api/businesses/overview.php" | python3 -m json.tool
```

Reading the database directly:

```bash
docker compose exec -T db mysql -uroot -proot app_manager -e "SELECT * FROM branches;"
```

Note that this `mysql` client defaults to latin1, so comparing Turkish text in
an ad-hoc query can throw a collation error. Filter by id instead. The app's PHP
connection uses utf8mb4 and is unaffected.
