diff --git a/README.md b/README.md
index 9c29748..cb188d7 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# kooltab2
+# KoolTap
A new Flutter project.
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 0d29021..89d8539 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -1,28 +1,15 @@
-# This file configures the analyzer, which statically analyzes Dart code to
-# check for errors, warnings, and lints.
-#
-# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
-# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
-# invoked from the command line by running `flutter analyze`.
-
-# The following line activates a set of recommended lints for Flutter apps,
-# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
-linter:
- # The lint rules applied to this project can be customized in the
- # section below to disable rules from the `package:flutter_lints/flutter.yaml`
- # included above or to enable additional rules. A list of all available lints
- # and their documentation is published at https://dart.dev/lints.
- #
- # Instead of disabling a lint rule for the entire project in the
- # section below, it can also be suppressed for a single line of code
- # or a specific dart file by using the `// ignore: name_of_lint` and
- # `// ignore_for_file: name_of_lint` syntax on the line or in the file
- # producing the lint.
- rules:
- # avoid_print: false # Uncomment to disable the `avoid_print` rule
- # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+analyzer:
+ exclude:
+ - lib/**/*.g.dart
+ - lib/l10n/app_localizations*.dart
-# Additional information about this file can be found at
-# https://dart.dev/guides/language/analysis-options
+linter:
+ rules:
+ prefer_relative_imports: true
+ prefer_single_quotes: true
+ unawaited_futures: true
+ directives_ordering: true
+ prefer_final_locals: true
+ use_build_context_synchronously: true
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index c345d3c..0fcdae6 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -15,7 +15,7 @@ plugins {
}
android {
- namespace = "dev.brammie15.kooltab"
+ namespace = "dev.brammie15.kooltap"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -28,7 +28,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
- applicationId = "dev.brammie15.kooltab"
+ applicationId = "dev.brammie15.kooltap"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7cf823f..1be6a22 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,7 +5,7 @@
deviceId` is always resolvable from the last-known state.
+- The dashboard never asks the client to pick a device. It resolves the target device from the tab the client selected.
+- If a tab appears in snapshots from more than one device, treat the most recent snapshot as authoritative and flag the older one. This should not happen in normal operation and is worth logging.
+- If a tab's owning device has not been seen recently, the dashboard warns before allowing a remote close.
+
+For a single-device venue this collapses to the trivial case, but building it in now avoids a schema migration when the second register arrives.
+
+## Bar App Sync
+
+Add a `RemoteSyncService` that runs after database initialization during app startup and then on the periodic timer described above. `AppBootstrap` is a suitable integration point because it already performs startup work.
+
+The service should:
+
+1. Read the device ID, authentication token, and sync cursor from secure storage.
+2. Call the sync endpoint.
+3. Ignore an empty command list.
+4. Process commands one at a time.
+5. Execute each command using the existing domain service.
+6. Acknowledge a command only after the local transaction succeeds.
+7. Persist the new cursor and processed command IDs.
+8. Surface the outcome to the bartender in the UI.
+9. Leave the app usable if the network request fails.
+
+The sync request must not block the bar from opening, and a periodic tick must never block interaction. A failed or unavailable server should be logged and retried on the next trigger.
+
+## Bartender Notification
+
+A tab that closes itself with no explanation is confusing and looks like a bug. When a remote command is applied, the app must tell the bartender.
+
+- Show a non-blocking toast or snackbar naming the tab and the requester, for example `Tab 12 (Jan) closed remotely by owner`.
+- If the bar screen is currently displaying that tab, dismiss or refresh the view rather than leaving stale item rows on screen.
+- Write the event into a locally visible remote-activity list in Admin, so the bartender can check what happened after the toast is gone.
+- If a command results in a conflict, the bartender does not need a toast. Nothing changed locally. Log it for the activity list only.
+
+If the open decision below is resolved in favour of bartender confirmation, this notification becomes a prompt with Confirm and Reject actions instead of a passive toast.
+
+## Local Database Additions
+
+The app should remember remote processing state locally.
+
+### Tab Version Column
+
+Add a `version` integer (or a reliable `updatedAt` timestamp) to the tabs table. This is a prerequisite for conflict handling, not a later addition.
+
+- Increment on every mutation: creating the tab, adding an item, changing a quantity, removing an item, and closing.
+- Include it in every open-tab snapshot upload.
+- The client sends it back as `expectedVersion` when requesting a close.
+
+Do this migration in the same phase as the snapshot upload, because a snapshot without a version is not usable for safe remote closing.
+
+### Sync State
+
+```text
+deviceId
+serverCursor
+lastSuccessfulSyncAt
+lastAttemptedSyncAt
+consecutiveFailureCount
+```
+
+### Processed Remote Commands
+
+```text
+commandId
+commandType
+processedAt
+result
+```
+
+The command ID is important. If the app receives the same command twice, it must not close the same tab twice or create duplicate history records. This matters more with periodic sync than with startup-only sync, because retries are more frequent.
+
+## Closing a Tab Remotely
+
+The local implementation already has the correct transaction boundary in `BarTabService.closeTab()`. The remote command handler should call that same method rather than duplicating the database logic.
+
+The remote close flow should verify:
+
+- The tab still exists.
+- The tab is still open.
+- The tab version matches the `expectedVersion` sent by the client.
+- The command has not already been processed.
+- The command has not expired.
+
+If the tab changed after the client requested closure, return a conflict rather than silently closing a newer version of the tab.
+
+Commands should expire. A `close_tab` command that has been pending for longer than a configured window (24 hours is a reasonable default) should be marked `expired` by the server and never applied. A day-old close request almost certainly no longer reflects what the client wants.
+
+## Payment Decision
+
+**Decision: the client must explicitly choose a payment method, and an unpaid remote close requires a dedicated state.**
+
+The current model requires every closed tab to have either `cash` or `payconiq` as its payment method. Rather than abusing the `cash` value, add a `payment_pending` value to the payment method enum.
+
+Behaviour:
+
+- The dashboard requires the client to pick Cash, Payconiq, or Leave unpaid before submitting.
+- If the client picks Cash or Payconiq, the remote close records that method and the tab closes normally.
+- If the client picks Leave unpaid, the tab closes with `payment_pending` and appears in a `Payment pending` list in the app until a bartender settles it.
+- The app never infers a payment method on the client's behalf.
+
+This decision needs to land before the command endpoint is built, because it changes the payment method enum, the tab history schema, and any reporting that groups closed tabs by payment method. Check every existing query that assumes exactly two payment methods.
+
+## State Synchronization
+
+The server needs enough information for the client to see open tabs. There are two reasonable levels of synchronization:
+
+### Minimal Snapshot
+
+- Upload open-tab state on startup and on each periodic tick, alongside the command fetch.
+- The client sees the last-known snapshot from the server.
+- Remote commands target a tab ID and expected version.
+- The bar app applies the command on the next sync.
+
+Since the periodic sync is already making a request, the snapshot can ride along in the same call. This gives the client data that is at most one polling interval stale, which is a significant improvement over the startup-only version at essentially no extra cost.
+
+### Outbound Updates After Mutations
+
+- Also upload state immediately after creating tabs, adding items, changing quantities, and closing tabs.
+- The client view becomes accurate within seconds rather than within a polling interval.
+
+This is still a useful second phase, but it is less urgent now that the snapshot is refreshed on every tick. Consider it if the polling interval has to be raised for cost or battery reasons.
+
+## Offline and Conflict Behavior
+
+If the bar device is offline:
+
+- The dashboard should show the device's last-seen time, sourced from `lastSuccessfulSyncAt` on the server side.
+- The dashboard should visibly warn when the last-seen time exceeds the polling interval by a meaningful margin, since it means the snapshot is stale.
+- New commands remain pending on the server until they are applied or expire.
+- The app processes them on its next successful sync.
+
+If a bartender changes or closes the tab before the remote command arrives:
+
+- The command should become `conflict` or `already_completed`.
+- The client should see what happened rather than receiving a false success.
+- The app should not overwrite newer local data.
+
+## Security
+
+The local regular PIN and Admin PIN are not suitable as remote authentication. They only protect the local device.
+
+Use separate remote authentication with:
+
+- Client accounts scoped to a venue.
+- HTTPS for every request, with certificate validation left at default. Do not add certificate exceptions for a development server.
+- Server-side role checks on every endpoint, including the sync endpoint.
+- Device registration and revocable device tokens.
+- Secure storage for the device token on the bar device.
+- Two-factor authentication for client accounts if appropriate.
+- An audit log containing who requested each remote close, which device applied it, and when.
+- No public endpoint that accepts a tab ID without authentication.
+
+### Rate Limiting
+
+- Limit command creation per client account, for example 10 commands per minute and 100 per day. A client has no legitimate reason to exceed this.
+- Limit sync polling per device to somewhat above the configured interval, so a misbehaving or misconfigured client cannot hammer the endpoint.
+- Limit authentication attempts per account and per IP, with lockout and alerting.
+- Return `429` with a `Retry-After` header. The app should respect it and extend its backoff accordingly.
+
+### Token Revocation
+
+Revocation needs to be usable by a venue owner under pressure, not just technically possible.
+
+- The dashboard lists registered devices with name, last-seen time, and a Revoke button.
+- Revoking a device invalidates its token immediately on the server. The next sync from that device returns `401`.
+- On `401`, the app clears the stored token, stops the sync timer, and shows a re-registration prompt in Admin. It must keep working as a local-first bar app in the meantime. Losing remote access must never stop the bar from serving.
+- Re-registration requires an Admin PIN on the device plus an authenticated action from the dashboard, so a stolen device cannot re-enrol itself.
+- Device tokens should have an expiry and a refresh path, so a device that quietly disappears stops being valid on its own.
+- Revocation events go in the audit log.
+
+## Suggested Implementation Phases
+
+1. Build the server database and authenticated device registration.
+2. Add the tab `version` column and increment it on every mutation.
+3. Add an endpoint for uploading the current open-tab snapshot, including version and owning device.
+4. Add a client dashboard showing last-known open tabs and device last-seen times.
+5. Add the `payment_pending` payment method to the data model and update affected queries and reports.
+6. Add the pending `close_tab` command endpoint with explicit payment method selection.
+7. Add startup sync to the Flutter app.
+8. Add local command deduplication and acknowledgement handling.
+9. Add expected-version conflict handling and command expiry.
+10. Add the periodic sync timer with backoff and in-flight guarding.
+11. Add bartender notification and the local remote-activity list.
+12. Add a manual `Sync now` action.
+13. Add rate limiting and the device revocation flow.
+14. Add outbound updates after local tab mutations if the periodic snapshot is still too stale.
+
+Phases 2 and 5 are schema changes and should land before anything depends on them. Phase 13 should not be deferred past first production use.
+
+## Open Decisions
+
+- Should remote closing happen automatically on the next sync, or should the bartender confirm it? This changes the notification in the Bartender Notification section from a toast to a prompt.
+- How many bars or devices need to be supported? This affects how much of the device targeting work is needed in the first version.
+- Is a separate web dashboard preferred, or should remote control be another Flutter app?
+- What polling interval balances freshness against server cost? The starting value of 60 to 120 seconds is a guess, not a measurement.
+- Should a `payment_pending` tab block end-of-day reporting until it is settled, or appear as its own reporting category?
+
+### Resolved
+
+- **Does the client choose the payment method remotely?** Yes. Explicit selection is required. See Payment Decision.
+- **Should a remote close support unpaid tabs?** Yes, through a dedicated `payment_pending` state rather than by recording it as cash.
+- **How stale may the client's open-tab data be before the dashboard warns them?** The snapshot refreshes on every sync tick, so the dashboard warns when the device's last-seen time exceeds roughly three polling intervals.
\ No newline at end of file
diff --git a/docs/remote_tab_control_api.md b/docs/remote_tab_control_api.md
new file mode 100644
index 0000000..5535f05
--- /dev/null
+++ b/docs/remote_tab_control_api.md
@@ -0,0 +1,523 @@
+# Remote Tab Control — Backend Specification
+
+This document describes the backend API and database requirements for the KoolTap remote tab control feature. The bar app (Flutter) is the sync client; the backend serves both the bar app API and a web dashboard.
+
+---
+
+## Database Schema
+
+### `venues`
+
+| Column | Type | Notes |
+|--------|------|-------|
+| `id` | `uuid` | Primary key |
+| `name` | `text` | Venue name |
+| `created_at` | `timestamptz` | |
+| `updated_at` | `timestamptz` | |
+
+### `devices`
+
+| Column | Type | Notes |
+|--------|------|-------|
+| `id` | `uuid` | Primary key, assigned by server on registration |
+| `venue_id` | `uuid` | FK → `venues.id` |
+| `name` | `text` | Human-readable label set during registration |
+| `token_hash` | `text` | BCrypt or SHA-256 hash of the device bearer token |
+| `token_prefix` | `text` | First 8 chars of the raw token for support identification |
+| `token_expires_at` | `timestamptz` | Token expiry |
+| `last_seen_at` | `timestamptz` | Updated on every sync |
+| `revoked_at` | `timestamptz?` | Null while active |
+| `created_at` | `timestamptz` | |
+
+Index: `(venue_id, revoked_at)`.
+
+### `client_users`
+
+| Column | Type | Notes |
+|--------|------|-------|
+| `id` | `uuid` | Primary key |
+| `venue_id` | `uuid` | FK → `venues.id` |
+| `email` | `text` | Login identifier |
+| `password_hash` | `text` | |
+| `name` | `text` | Display name, logged as `requested_by` on commands |
+| `role` | `text` | `admin` or `viewer` |
+| `created_at` | `timestamptz` | |
+
+Unique: `(email)`.
+A client user is scoped to one venue. A venue may have many client users.
+
+### `open_tab_snapshots`
+
+Stores the last-known open-tab state reported by each device.
+
+| Column | Type | Notes |
+|--------|------|-------|
+| `id` | `uuid` | Primary key |
+| `device_id` | `uuid` | FK → `devices.id` |
+| `tab_id` | `text` | The tab's ID in the bar app's local database |
+| `customer_name` | `text` | |
+| `version` | `int` | Tab version at time of snapshot |
+| `total_in_cents` | `int` | |
+| `item_count` | `int` | |
+| `reported_at` | `timestamptz` | When the device reported this state |
+
+Index: `(device_id)`.
+Index: `(tab_id, reported_at)`.
+The server keeps only the most recent snapshot per `(device_id, tab_id)`. On each sync upload, upsert by `(device_id, tab_id)`.
+
+### `tab_ownership`
+
+Resolves which device currently owns a tab.
+
+| Column | Type | Notes |
+|--------|------|-------|
+| `tab_id` | `text` | The bar app's tab ID |
+| `venue_id` | `uuid` | FK → `venues.id` |
+| `device_id` | `uuid` | FK → `devices.id` |
+| `last_reported_at` | `timestamptz` | |
+
+Primary key: `(venue_id, tab_id)`.
+Updated on every snapshot upload. If the same `tab_id` is reported by a different `device_id`, the most recent `reported_at` wins — the older one is flagged in the audit log.
+
+### `remote_commands`
+
+| Column | Type | Notes |
+|--------|------|-------|
+| `id` | `uuid` | Primary key |
+| `venue_id` | `uuid` | FK → `venues.id` |
+| `device_id` | `uuid` | FK → `devices.id`, resolved from `tab_ownership` |
+| `target_tab_id` | `text` | |
+| `type` | `text` | Currently only `close_tab` |
+| `expected_version` | `int` | Sent by the dashboard, validated by the bar app |
+| `payment_method` | `text` | `cash`, `payconiq`, or `payment_pending` |
+| `requested_by` | `text` | Client user's display name |
+| `status` | `text` | `pending`, `completed`, `failed`, `conflict`, `expired` |
+| `result` | `text?` | Free-text result from the bar app |
+| `acknowledged_at` | `timestamptz?` | When the bar app acked |
+| `created_at` | `timestamptz` | |
+| `expires_at` | `timestamptz` | `created_at + 24 hours` |
+
+Index: `(device_id, status, created_at)`.
+Index: `(venue_id, status, created_at)` for the dashboard.
+
+### `audit_log`
+
+| Column | Type | Notes |
+|--------|------|-------|
+| `id` | `uuid` | Primary key |
+| `venue_id` | `uuid` | FK → `venues.id` |
+| `actor_type` | `text` | `client_user` or `device` or `system` |
+| `actor_id` | `text` | The ID of who did it |
+| `action` | `text` | `command_created`, `command_acknowledged`, `command_expired`, `device_registered`, `device_revoked`, `snapshot_conflict` |
+| `details` | `jsonb?` | Arbitrary context |
+| `created_at` | `timestamptz` | |
+
+Index: `(venue_id, created_at)`.
+
+---
+
+## API Endpoints
+
+Base URL: `https://api.kooltap.com/api/v1`
+
+All endpoints require HTTPS. The bar app endpoints require a valid device bearer token. The dashboard endpoints require a valid client session token.
+
+---
+
+### 1. Device Registration
+
+**`POST /devices/register`**
+
+Registers a new bar device and returns a bearer token. The device calls this once during setup (initiated from the Admin screen with an Admin PIN + a registration code from the dashboard).
+
+**Request:**
+
+```json
+{
+ "venueId": "uuid",
+ "name": "Bar terminal 1",
+ "registrationCode": "ABC123"
+}
+```
+
+**Response (201):**
+
+```json
+{
+ "deviceId": "uuid",
+ "token": "dev_abc123...",
+ "tokenExpiresAt": "2026-01-01T00:00:00Z"
+}
+```
+
+**Response (401):** Invalid registration code or venue mismatch.
+
+The raw token is returned only once. The device stores it in secure storage. The server stores only the hash.
+
+---
+
+### 2. Sync (Bar App → Server)
+
+**`POST /devices/:deviceId/sync`**
+
+Two-direction sync: the bar app uploads its current open-tab snapshot and receives any pending commands.
+
+**Headers:** `Authorization: Bearer `
+
+**Request:**
+
+```json
+{
+ "deviceId": "uuid",
+ "cursor": "optional-cursor-from-last-response",
+ "openTabs": [
+ {
+ "id": "local-tab-uuid",
+ "customerName": "Jan",
+ "version": 5,
+ "totalInCents": 2450,
+ "itemCount": 3
+ }
+ ]
+}
+```
+
+- `cursor` is opaque to the device. The server uses it to determine which commands the device has already seen. If absent, the server returns all pending commands.
+- `openTabs` is the full list of currently open tabs. The server upserts `open_tab_snapshots` and `tab_ownership`.
+
+**Response (200):**
+
+```json
+{
+ "cursor": "new-cursor-value",
+ "commands": [
+ {
+ "id": "cmd-uuid",
+ "type": "close_tab",
+ "tabId": "local-tab-uuid",
+ "expectedVersion": 5,
+ "paymentMethod": "cash",
+ "requestedBy": "Eigenaar",
+ "createdAt": "2026-09-16T21:00:00Z"
+ }
+ ]
+}
+```
+
+If no commands are pending, `commands` is an empty array.
+
+**Server-side logic:**
+
+1. Validate the device token and look up `venue_id`.
+2. Upsert each reported open tab into `open_tab_snapshots` by `(device_id, tab_id)`.
+3. Upsert `tab_ownership` by `(venue_id, tab_id)`. If the tab was previously owned by a different device, log a `snapshot_conflict` audit event.
+4. Update `devices.last_seen_at`.
+5. Query `remote_commands` where `device_id` matches, `status = 'pending'`, and `created_at > cursor_cutoff`. Exclude commands that appear before the cursor.
+6. Mark any commands past `expires_at` as `expired`.
+7. Return the new cursor (e.g. ISO 8601 timestamp of `now()`) and the pending command list.
+
+**Rate limiting:** One request per 30 seconds per device. Return `429` with `Retry-After`.
+
+---
+
+### 3. Command Acknowledgement
+
+**`POST /devices/:deviceId/commands/:commandId/ack`**
+
+The bar app calls this after it has processed a command (success, failure, or conflict).
+
+**Headers:** `Authorization: Bearer `
+
+**Request:**
+
+```json
+{
+ "commandId": "cmd-uuid",
+ "status": "completed",
+ "result": "Tab closed successfully"
+}
+```
+
+`status` is one of: `completed`, `failed`, `conflict`, `expired`.
+
+**Response (200):**
+
+```json
+{
+ "ok": true
+}
+```
+
+**Server-side logic:**
+
+1. Update `remote_commands.status` and `remote_commands.result` and `remote_commands.acknowledged_at`.
+2. Write an audit log entry.
+
+The ack endpoint is idempotent. A second ack with the same `commandId` is accepted but does not change anything.
+
+---
+
+### 4. Create Command (Dashboard)
+
+**`POST /venues/:venueId/commands`**
+
+The dashboard calls this when a client user requests a remote tab close.
+
+**Headers:** `Authorization: Bearer `
+
+**Request:**
+
+```json
+{
+ "tabId": "local-tab-uuid",
+ "type": "close_tab",
+ "expectedVersion": 5,
+ "paymentMethod": "cash"
+}
+```
+
+The `paymentMethod` must be one of `cash`, `payconiq`, or `payment_pending`. The dashboard must require the client to explicitly select one — never default to `cash` silently.
+
+**Response (201):**
+
+```json
+{
+ "id": "cmd-uuid",
+ "status": "pending"
+}
+```
+
+**Server-side logic:**
+
+1. Validate the client session and look up `venue_id`.
+2. Resolve the target `device_id` from `tab_ownership` using `(venue_id, tabId)`.
+3. If no device owns this tab, return `404` with a message that the tab is not associated with any device.
+4. If the owning device has not been seen (last_seen_at) in the last 10 minutes, return `200` but include a warning: `"warning": "Device has not been seen recently"`. The dashboard should display this warning.
+5. Insert into `remote_commands` with `status = 'pending'`, `expires_at = now() + 24 hours`, and `device_id` from step 2.
+6. Write an audit log entry.
+
+**Rate limiting:** 10 requests per minute per client user. Return `429` with `Retry-After`.
+
+---
+
+### 5. Dashboard: List Open Tabs
+
+**`GET /venues/:venueId/tabs`**
+
+Returns the most recent snapshot of every open tab across all devices in the venue.
+
+**Headers:** `Authorization: Bearer `
+
+**Response (200):**
+
+```json
+{
+ "tabs": [
+ {
+ "tabId": "local-tab-uuid",
+ "customerName": "Jan",
+ "version": 5,
+ "totalInCents": 2450,
+ "itemCount": 3,
+ "deviceId": "device-uuid",
+ "deviceName": "Bar terminal 1",
+ "deviceLastSeenAt": "2026-09-17T20:30:00Z",
+ "lastReportedAt": "2026-09-17T20:30:00Z"
+ }
+ ]
+}
+```
+
+The dashboard should show a warning next to tabs whose `deviceLastSeenAt` is more than 5 minutes ago.
+
+---
+
+### 6. Dashboard: List Devices
+
+**`GET /venues/:venueId/devices`**
+
+**Headers:** `Authorization: Bearer `
+
+**Response (200):**
+
+```json
+{
+ "devices": [
+ {
+ "id": "uuid",
+ "name": "Bar terminal 1",
+ "lastSeenAt": "2026-09-17T20:30:00Z",
+ "revokedAt": null
+ }
+ ]
+}
+```
+
+---
+
+### 7. Dashboard: Revoke Device
+
+**`POST /venues/:venueId/devices/:deviceId/revoke`**
+
+**Headers:** `Authorization: Bearer `
+**Role required:** `admin`
+
+**Request:** (empty body)
+
+**Response (200):**
+
+```json
+{
+ "ok": true
+}
+```
+
+**Server-side logic:**
+
+1. Set `devices.revoked_at` to `now()`.
+2. The device's token is immediately invalid. The next sync from this device returns `401`.
+3. Any pending commands for this device are set to `expired`.
+4. Write an audit log entry.
+
+---
+
+### 8. Dashboard: Command History
+
+**`GET /venues/:venueId/commands`**
+
+**Query params:** `?status=pending&limit=50&offset=0`
+
+**Headers:** `Authorization: Bearer `
+
+**Response (200):**
+
+```json
+{
+ "commands": [
+ {
+ "id": "uuid",
+ "tabId": "local-tab-uuid",
+ "type": "close_tab",
+ "status": "pending",
+ "paymentMethod": "cash",
+ "requestedBy": "Eigenaar",
+ "createdAt": "2026-09-16T21:00:00Z",
+ "processedAt": null,
+ "result": null
+ }
+ ],
+ "total": 1
+}
+```
+
+---
+
+## Authentication & Security
+
+### Device Tokens
+
+- Format: `dev_` prefix + 48 random bytes, base64-url-encoded (64 chars).
+- Stored as a SHA-256 hash in `devices.token_sh`.
+- Expire after 90 days. The device receives a new token on re-registration.
+- Revocation sets `evoked_at` immediately.
+
+### Client Authentication
+
+- Standard email/password with bcrypt.
+- Sessions via JWT with 24-hour expiry and refresh token.
+- Optional 2FA (TOTP) for admin role.
+
+### General Rules
+
+- Every endpoint validates the caller's venue scope. A device or client from venue A cannot access data from venue B.
+- No public endpoint accepts a `tabId` without authentication.
+- CORS is restricted to the dashboard origin only.
+
+---
+
+## Command Expiry
+
+A `close_tab` command expires 24 hours after creation.
+
+**Mechanism:**
+
+- The `expires_at` column is set on insert.
+- The sync endpoint queries `WHERE status = 'pending' AND expires_at > now()`. Any rows where `expires_at <= now()` are updated to `status = 'expired'` before returning.
+- A separate cron job (every hour) marks expired commands and writes audit log entries for housekeeping.
+
+---
+
+## Error Responses
+
+All errors follow this shape:
+
+```json
+{
+ "error": {
+ "code": "device_not_found",
+ "message": "No device found with the given ID"
+ }
+}
+```
+
+HTTP status codes:
+
+| Code | Meaning |
+|------|---------|
+| 200 | Success |
+| 201 | Created |
+| 400 | Bad reqest (validation) |
+| 401 | Unauthorized (missing or expired token) |
+| 403 | Forbidden (wrong role) |
+| 404 | Not found |
+| 409 | Conflict (version mismatch etc.) |
+| 429 | Too many request (`Retry-After` header) |
+
+---
+
+## Sequence: Full Close Flow
+
+```
+Dashboard client selects tab → clicks "Close"
+ │
+ ▼
+Dashboard POST /venues/:id/commands { tabId, expectedVersion, paymentMethod }
+ │
+ ▼
+Server resolves deviceId from tab_ownership
+Server inserts remote_command (status=pending)
+Server returns commandId + status=pending
+ │
+ ▼
+Bar app syncs (every 90s or on resume)
+ POST /devices/:id/sync { openTabs, cursor }
+ │
+ ▼
+ Server returns [pending command]
+ │
+ ▼
+ Bar app processes command:
+ ├─ Tab not found → ack conflict
+ ├── Tab already closed → ack conflict
+ ├── Version mismatch → ack conflict
+ └── Success → closes tab → ak completed
+ │
+ ▼
+ Bar app POST /devices/:id/commands/:id/ack { status, result }
+ │
+ ▼
+ Server updates command status + audit log
+```essages notification
+
+The bar app shows a toast. The dashboard sees `status = completed` on next poll.
+
+---
+
+## Implementation Priority
+
+1. **Phase 1** — `venues`, `devices`, `client_users` tables + device registration + basic auth
+2. **Phase 2** — `open_tab_snapshots` + `tab_ownership` + sync endpoint (ignore commands, just store state)
+3. **Phase 3** — `remote_commands` + `audit_log` + command creation + command ack + full sync loop
+4. **Phase 4** — Dashboard read endpoints (list tabs, list devices, command history)
+5. **Phase 5** — Device revocation + rate limiting + command expiry cron
+6. **Phase 6** — Optional: 2FA, refresh tokens, outbound state push after mutations
\ No newline at end of file
diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml
index 7b90c6f..7c107b3 100644
--- a/flutter_launcher_icons.yaml
+++ b/flutter_launcher_icons.yaml
@@ -1,9 +1,9 @@
# flutter pub run flutter_launcher_icons
flutter_launcher_icons:
- image_path: "assets/icons/kooltab_logo.png"
+ image_path: "assets/icons/kooltap_logo.png"
android: "launcher_icon"
-# image_path_android: "assets/icons/kooltab_logo.png"
+# image_path_android: "assets/icons/kooltap_logo.png"
min_sdk_android: 21 # android min sdk min:16, default 21
# adaptive_icon_background: "assets/icon/background.png"
# adaptive_icon_foreground: "assets/icon/foreground.png"
@@ -20,13 +20,13 @@ flutter_launcher_icons:
web:
generate: false
- image_path: "assets/icons/kooltab_logo.png"
+ image_path: "assets/icons/kooltap_logo.png"
background_color: "#hexcode"
theme_color: "#hexcode"
windows:
generate: true
- image_path: "assets/icons/kooltab_logo.png"
+ image_path: "assets/icons/kooltap_logo.png"
icon_size: 48 # min:48, max:256, default: 48
macos:
diff --git a/lib/app/app.dart b/lib/app/app.dart
index 943dc37..b46d53c 100644
--- a/lib/app/app.dart
+++ b/lib/app/app.dart
@@ -2,25 +2,25 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
-import 'app_lifecycle_lock.dart';
+import '../l10n/app_localizations.dart';
import '../models/settings.dart';
+import '../theme.dart';
import '../viewmodels/admin_pin_view_model.dart';
import '../viewmodels/pin_lock_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
import '../viewmodels/settings_view_model.dart';
-import '../theme.dart';
-import '../l10n/app_localizations.dart';
+import 'app_lifecycle_lock.dart';
-class KoolTabApp extends StatefulWidget {
- const KoolTabApp({super.key, required this.router});
+class KoolTapApp extends StatefulWidget {
+ const KoolTapApp({super.key, required this.router});
final GoRouter router;
@override
- State createState() => _KoolTabAppState();
+ State createState() => _KoolTapAppState();
}
-class _KoolTabAppState extends State {
+class _KoolTapAppState extends State {
late final AppLifecycleLockObserver _lifecycleLockObserver;
@override
diff --git a/lib/app/app_bootstrap.dart b/lib/app/app_bootstrap.dart
index 46e5a34..875e265 100644
--- a/lib/app/app_bootstrap.dart
+++ b/lib/app/app_bootstrap.dart
@@ -1,14 +1,17 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
import '../database/app_database.dart';
+import '../services/bar_tab_service.dart';
import '../services/default_product_seeder.dart';
import '../services/export_service.dart';
import '../viewmodels/bar_screen_view_model.dart';
import '../viewmodels/product_list_view_model.dart';
-import '../viewmodels/remote_sync_view_model.dart';
+/// Kicks off the work that can run after the first frame.
class AppBootstrap extends StatefulWidget {
final Widget child;
@@ -26,17 +29,24 @@ class _AppBootstrapState extends State {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
- final database = context.read();
- DefaultProductSeeder(database: database).seedIfEmpty();
+ unawaited(
+ DefaultProductSeeder(
+ database: context.read(),
+ ).seedIfEmpty(),
+ );
context.read().ensureLoaded();
context.read().ensureLoaded();
- DefaultExportService(database: database).uploadToServer();
+ unawaited(
+ DefaultExportService(
+ barTabService: context.read(),
+ ).uploadToServer(),
+ );
- context.read().init();
-
- SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
+ unawaited(
+ SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
+ );
});
}
@@ -44,4 +54,4 @@ class _AppBootstrapState extends State {
Widget build(BuildContext context) {
return widget.child;
}
-}
\ No newline at end of file
+}
diff --git a/lib/app/router.dart b/lib/app/router.dart
index 53fb840..606b3a7 100644
--- a/lib/app/router.dart
+++ b/lib/app/router.dart
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
+import '../utils/app_update_util.dart';
import '../viewmodels/admin_pin_view_model.dart';
import '../viewmodels/pin_lock_view_model.dart';
+import '../viewmodels/remote_sync_view_model.dart';
import '../views/admin_screen_view.dart';
-import '../views/bar_screen_view.dart';
+import '../views/bar/bar_screen_view.dart';
import '../views/dev_menu_view.dart';
import '../views/error_screen_view.dart';
import '../views/history_screen_view.dart';
@@ -17,22 +19,23 @@ import '../views/product_list_view.dart';
import '../views/registration_view.dart';
import '../views/settings_view.dart';
import '../views/update_progress_view.dart';
-import '../utils/app_update_util.dart';
final RouteObserver routeObserver = RouteObserver();
String? _redirectApp({
required PinLockViewModel pinLockViewModel,
required AdminPinViewModel adminPinViewModel,
- required ValueNotifier isConfiguredNotifier,
+ required RemoteSyncViewModel remoteSyncViewModel,
required GoRouterState state,
}) {
- if (!pinLockViewModel.hasLoaded || !adminPinViewModel.hasLoaded) {
+ if (!pinLockViewModel.hasLoaded ||
+ !adminPinViewModel.hasLoaded ||
+ !remoteSyncViewModel.hasLoaded) {
return null;
}
final location = state.uri.path;
- final isConfigured = isConfiguredNotifier.value;
+ final isConfigured = remoteSyncViewModel.isConfigured;
if (!isConfigured && location != '/register') {
return '/register';
@@ -71,8 +74,8 @@ bool _mustBeOnLock(PinLockViewModel pinLockViewModel) {
!pinLockViewModel.isPinSet && pinLockViewModel.pinRequired;
final isLocked =
pinLockViewModel.isPinSet &&
- !pinLockViewModel.isUnlocked &&
- pinLockViewModel.pinRequired;
+ !pinLockViewModel.isUnlocked &&
+ pinLockViewModel.pinRequired;
return needsPinSetup || isLocked;
}
@@ -95,9 +98,10 @@ bool _isDebugShortcutArea(String location) {
location.startsWith('/history/');
}
-GoRouter createAppRouter(PinLockViewModel pinLockViewModel, {
+GoRouter createAppRouter({
+ required PinLockViewModel pinLockViewModel,
required AdminPinViewModel adminPinViewModel,
- required ValueNotifier isConfiguredNotifier,
+ required RemoteSyncViewModel remoteSyncViewModel,
}) {
return GoRouter(
initialLocation: '/bar',
@@ -105,15 +109,14 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel, {
refreshListenable: Listenable.merge([
pinLockViewModel,
adminPinViewModel,
- isConfiguredNotifier,
+ remoteSyncViewModel,
]),
- redirect: (_, state) =>
- _redirectApp(
- pinLockViewModel: pinLockViewModel,
- adminPinViewModel: adminPinViewModel,
- isConfiguredNotifier: isConfiguredNotifier,
- state: state,
- ),
+ redirect: (_, state) => _redirectApp(
+ pinLockViewModel: pinLockViewModel,
+ adminPinViewModel: adminPinViewModel,
+ remoteSyncViewModel: remoteSyncViewModel,
+ state: state,
+ ),
routes: [
GoRoute(path: '/', redirect: (context, state) => '/bar'),
@@ -124,32 +127,30 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel, {
GoRoute(
path: '/lock',
- builder: (context, state) =>
- PinEntryView(
- mode: pinLockViewModel.isPinSet
- ? PinEntryMode.unlock
- : PinEntryMode.create,
- onSuccess: () {
- context.go("/bar");
- },
- ),
+ builder: (context, state) => PinEntryView(
+ mode: pinLockViewModel.isPinSet
+ ? PinEntryMode.unlock
+ : PinEntryMode.create,
+ onSuccess: () {
+ context.go('/bar');
+ },
+ ),
),
GoRoute(path: '/bar', builder: (context, state) => const BarScreenView()),
GoRoute(
path: '/admin/lock',
- builder: (context, state) =>
- PinEntryView(
- mode: adminPinViewModel.isPinSet
- ? PinEntryMode.unlock
- : PinEntryMode.create,
- isAdmin: true,
- onVerify: adminPinViewModel.verify,
- onSet: adminPinViewModel.setPin,
- errorMessage: () => adminPinViewModel.errorMessage,
- onSuccess: () => context.go('/admin'),
- ),
+ builder: (context, state) => PinEntryView(
+ mode: adminPinViewModel.isPinSet
+ ? PinEntryMode.unlock
+ : PinEntryMode.create,
+ isAdmin: true,
+ onVerify: adminPinViewModel.verify,
+ onSet: adminPinViewModel.setPin,
+ error: () => adminPinViewModel.error,
+ onSuccess: () => context.go('/admin'),
+ ),
),
GoRoute(
@@ -213,4 +214,4 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel, {
),
],
);
-}
\ No newline at end of file
+}
diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart
index 673c29a..9a20d0f 100644
--- a/lib/database/app_database.dart
+++ b/lib/database/app_database.dart
@@ -1,9 +1,23 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path_provider/path_provider.dart';
+import 'package:uuid/uuid.dart';
+
+import '../models/payment_method.dart';
+import '../models/remote_command.dart';
part 'app_database.g.dart';
+class PaymentMethodConverter extends TypeConverter {
+ const PaymentMethodConverter();
+
+ @override
+ PaymentMethod fromSql(String fromDb) => PaymentMethod.fromValue(fromDb);
+
+ @override
+ String toSql(PaymentMethod value) => value.value;
+}
+
@DataClassName('ProductRow')
class Products extends Table {
TextColumn get id => text()();
@@ -32,12 +46,8 @@ class BarTabs extends Table {
TextColumn get customerName => text()();
- TextColumn get status => text().withDefault(const Constant('open'))();
-
DateTimeColumn get openedAt => dateTime()();
- DateTimeColumn get closedAt => dateTime().nullable()();
-
IntColumn get version => integer().withDefault(const Constant(0))();
@override
@@ -90,7 +100,9 @@ class ClosedTabs extends Table {
DateTimeColumn get closedAt => dateTime()();
- TextColumn get paymentMethod => text().withDefault(const Constant('cash'))();
+ TextColumn get paymentMethod => text()
+ .map(const PaymentMethodConverter())
+ .withDefault(const Constant('cash'))();
@override
Set get primaryKey => {id};
@@ -150,7 +162,8 @@ class RemoteSyncState extends Table {
DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()();
- IntColumn get consecutiveFailureCount => integer().withDefault(const Constant(0))();
+ IntColumn get consecutiveFailureCount =>
+ integer().withDefault(const Constant(0))();
@override
Set get primaryKey => {deviceId};
@@ -159,9 +172,12 @@ class RemoteSyncState extends Table {
@DataClassName('ProcessedRemoteCommandRow')
class ProcessedRemoteCommands extends Table {
TextColumn get commandId => text()();
+
TextColumn get commandType => text()();
+
DateTimeColumn get processedAt => dateTime()();
- TextColumn get result => text()();
+
+ TextColumn get result => textEnum()();
@override
Set get primaryKey => {commandId};
@@ -184,7 +200,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
- int get schemaVersion => 12;
+ int get schemaVersion => 13;
@override
MigrationStrategy get migration {
@@ -269,16 +285,68 @@ class AppDatabase extends _$AppDatabase {
await migrator.createTable(remoteSyncState);
await migrator.createTable(processedRemoteCommands);
}
+
+ if (from < 13) {
+ // Tabs never close (only their items are archived), so the status
+ // and closed_at columns were never anything but 'open' and null.
+ if (await hasTableColumn('bar_tabs', 'status')) {
+ await migrator.alterTable(TableMigration(barTabs));
+ }
+ await _backfillPurchaseRows();
+ await _deleteOrphanedPurchaseRows();
+ }
},
);
}
+ /// Items added before purchases were tracked have fewer active purchase
+ /// rows than their quantity. Insert the missing ones once, here, so the
+ /// services can rely on `quantity == active purchases`.
+ Future _backfillPurchaseRows() async {
+ const uuid = Uuid();
+
+ final rows = await customSelect(
+ 'SELECT i.id, i.quantity, i.created_at, '
+ '(SELECT COUNT(*) FROM tab_item_purchases p '
+ 'WHERE p.tab_item_id = i.id AND p.removed_at IS NULL) AS active_count '
+ 'FROM tab_items i',
+ readsFrom: {tabItems, tabItemPurchases},
+ ).get();
+
+ for (final row in rows) {
+ final missing = row.read('quantity') - row.read('active_count');
+ if (missing <= 0) continue;
+
+ final itemId = row.read('id');
+ final createdAt = row.read('created_at');
+
+ for (var index = 0; index < missing; index++) {
+ await into(tabItemPurchases).insert(
+ TabItemPurchasesCompanion.insert(
+ id: uuid.v4(),
+ tabItemId: itemId,
+ purchasedAt: createdAt,
+ ),
+ );
+ }
+ }
+ }
+
+ /// Foreign keys aren't enforced, so deleting tab items used to leave their
+ /// purchase rows behind.
+ Future _deleteOrphanedPurchaseRows() async {
+ await customStatement(
+ 'DELETE FROM tab_item_purchases '
+ 'WHERE tab_item_id NOT IN (SELECT id FROM tab_items)',
+ );
+ }
+
static QueryExecutor _openConnection() {
return driftDatabase(
- name: 'kooltab',
+ name: 'kooltap',
native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory,
),
);
}
-}
\ No newline at end of file
+}
diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart
index 86962a2..b484eb5 100644
--- a/lib/database/app_database.g.dart
+++ b/lib/database/app_database.g.dart
@@ -553,16 +553,6 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
type: DriftSqlType.string,
requiredDuringInsert: true,
);
- static const VerificationMeta _statusMeta = const VerificationMeta('status');
- @override
- late final GeneratedColumn status = GeneratedColumn(
- 'status',
- aliasedName,
- false,
- type: DriftSqlType.string,
- requiredDuringInsert: false,
- defaultValue: const Constant('open'),
- );
static const VerificationMeta _openedAtMeta = const VerificationMeta(
'openedAt',
);
@@ -574,17 +564,6 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
- static const VerificationMeta _closedAtMeta = const VerificationMeta(
- 'closedAt',
- );
- @override
- late final GeneratedColumn closedAt = GeneratedColumn(
- 'closed_at',
- aliasedName,
- true,
- type: DriftSqlType.dateTime,
- requiredDuringInsert: false,
- );
static const VerificationMeta _versionMeta = const VerificationMeta(
'version',
);
@@ -598,14 +577,7 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
defaultValue: const Constant(0),
);
@override
- List get $columns => [
- id,
- customerName,
- status,
- openedAt,
- closedAt,
- version,
- ];
+ List get $columns => [id, customerName, openedAt, version];
@override
String get aliasedName => _alias ?? actualTableName;
@override
@@ -634,12 +606,6 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
} else if (isInserting) {
context.missing(_customerNameMeta);
}
- if (data.containsKey('status')) {
- context.handle(
- _statusMeta,
- status.isAcceptableOrUnknown(data['status']!, _statusMeta),
- );
- }
if (data.containsKey('opened_at')) {
context.handle(
_openedAtMeta,
@@ -648,12 +614,6 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
} else if (isInserting) {
context.missing(_openedAtMeta);
}
- if (data.containsKey('closed_at')) {
- context.handle(
- _closedAtMeta,
- closedAt.isAcceptableOrUnknown(data['closed_at']!, _closedAtMeta),
- );
- }
if (data.containsKey('version')) {
context.handle(
_versionMeta,
@@ -677,18 +637,10 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
DriftSqlType.string,
data['${effectivePrefix}customer_name'],
)!,
- status: attachedDatabase.typeMapping.read(
- DriftSqlType.string,
- data['${effectivePrefix}status'],
- )!,
openedAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}opened_at'],
)!,
- closedAt: attachedDatabase.typeMapping.read(
- DriftSqlType.dateTime,
- data['${effectivePrefix}closed_at'],
- ),
version: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}version'],
@@ -705,16 +657,12 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> {
class BarTabRow extends DataClass implements Insertable {
final String id;
final String customerName;
- final String status;
final DateTime openedAt;
- final DateTime? closedAt;
final int version;
const BarTabRow({
required this.id,
required this.customerName,
- required this.status,
required this.openedAt,
- this.closedAt,
required this.version,
});
@override
@@ -722,11 +670,7 @@ class BarTabRow extends DataClass implements Insertable {
final map = {};
map['id'] = Variable(id);
map['customer_name'] = Variable(customerName);
- map['status'] = Variable(status);
map['opened_at'] = Variable(openedAt);
- if (!nullToAbsent || closedAt != null) {
- map['closed_at'] = Variable(closedAt);
- }
map['version'] = Variable(version);
return map;
}
@@ -735,11 +679,7 @@ class BarTabRow extends DataClass implements Insertable {
return BarTabsCompanion(
id: Value(id),
customerName: Value(customerName),
- status: Value(status),
openedAt: Value(openedAt),
- closedAt: closedAt == null && nullToAbsent
- ? const Value.absent()
- : Value(closedAt),
version: Value(version),
);
}
@@ -752,9 +692,7 @@ class BarTabRow extends DataClass implements Insertable {
return BarTabRow(
id: serializer.fromJson(json['id']),
customerName: serializer.fromJson(json['customerName']),
- status: serializer.fromJson(json['status']),
openedAt: serializer.fromJson(json['openedAt']),
- closedAt: serializer.fromJson(json['closedAt']),
version: serializer.fromJson(json['version']),
);
}
@@ -764,9 +702,7 @@ class BarTabRow extends DataClass implements Insertable {
return {
'id': serializer.toJson(id),
'customerName': serializer.toJson(customerName),
- 'status': serializer.toJson(status),
'openedAt': serializer.toJson(openedAt),
- 'closedAt': serializer.toJson(closedAt),
'version': serializer.toJson(version),
};
}
@@ -774,16 +710,12 @@ class BarTabRow extends DataClass implements Insertable {
BarTabRow copyWith({
String? id,
String? customerName,
- String? status,
DateTime? openedAt,
- Value closedAt = const Value.absent(),
int? version,
}) => BarTabRow(
id: id ?? this.id,
customerName: customerName ?? this.customerName,
- status: status ?? this.status,
openedAt: openedAt ?? this.openedAt,
- closedAt: closedAt.present ? closedAt.value : this.closedAt,
version: version ?? this.version,
);
BarTabRow copyWithCompanion(BarTabsCompanion data) {
@@ -792,9 +724,7 @@ class BarTabRow extends DataClass implements Insertable {
customerName: data.customerName.present
? data.customerName.value
: this.customerName,
- status: data.status.present ? data.status.value : this.status,
openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt,
- closedAt: data.closedAt.present ? data.closedAt.value : this.closedAt,
version: data.version.present ? data.version.value : this.version,
);
}
@@ -804,52 +734,41 @@ class BarTabRow extends DataClass implements Insertable {
return (StringBuffer('BarTabRow(')
..write('id: $id, ')
..write('customerName: $customerName, ')
- ..write('status: $status, ')
..write('openedAt: $openedAt, ')
- ..write('closedAt: $closedAt, ')
..write('version: $version')
..write(')'))
.toString();
}
@override
- int get hashCode =>
- Object.hash(id, customerName, status, openedAt, closedAt, version);
+ int get hashCode => Object.hash(id, customerName, openedAt, version);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is BarTabRow &&
other.id == this.id &&
other.customerName == this.customerName &&
- other.status == this.status &&
other.openedAt == this.openedAt &&
- other.closedAt == this.closedAt &&
other.version == this.version);
}
class BarTabsCompanion extends UpdateCompanion {
final Value id;
final Value customerName;
- final Value status;
final Value openedAt;
- final Value closedAt;
final Value version;
final Value rowid;
const BarTabsCompanion({
this.id = const Value.absent(),
this.customerName = const Value.absent(),
- this.status = const Value.absent(),
this.openedAt = const Value.absent(),
- this.closedAt = const Value.absent(),
this.version = const Value.absent(),
this.rowid = const Value.absent(),
});
BarTabsCompanion.insert({
required String id,
required String customerName,
- this.status = const Value.absent(),
required DateTime openedAt,
- this.closedAt = const Value.absent(),
this.version = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
@@ -858,18 +777,14 @@ class BarTabsCompanion extends UpdateCompanion {
static Insertable custom({
Expression? id,
Expression? customerName,
- Expression? status,
Expression? openedAt,
- Expression? closedAt,
Expression? version,
Expression? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (customerName != null) 'customer_name': customerName,
- if (status != null) 'status': status,
if (openedAt != null) 'opened_at': openedAt,
- if (closedAt != null) 'closed_at': closedAt,
if (version != null) 'version': version,
if (rowid != null) 'rowid': rowid,
});
@@ -878,18 +793,14 @@ class BarTabsCompanion extends UpdateCompanion {
BarTabsCompanion copyWith({
Value? id,
Value? customerName,
- Value? status,
Value? openedAt,
- Value? closedAt,
Value? version,
Value? rowid,
}) {
return BarTabsCompanion(
id: id ?? this.id,
customerName: customerName ?? this.customerName,
- status: status ?? this.status,
openedAt: openedAt ?? this.openedAt,
- closedAt: closedAt ?? this.closedAt,
version: version ?? this.version,
rowid: rowid ?? this.rowid,
);
@@ -904,15 +815,9 @@ class BarTabsCompanion extends UpdateCompanion {
if (customerName.present) {
map['customer_name'] = Variable(customerName.value);
}
- if (status.present) {
- map['status'] = Variable(status.value);
- }
if (openedAt.present) {
map['opened_at'] = Variable(openedAt.value);
}
- if (closedAt.present) {
- map['closed_at'] = Variable(closedAt.value);
- }
if (version.present) {
map['version'] = Variable(version.value);
}
@@ -927,9 +832,7 @@ class BarTabsCompanion extends UpdateCompanion {
return (StringBuffer('BarTabsCompanion(')
..write('id: $id, ')
..write('customerName: $customerName, ')
- ..write('status: $status, ')
..write('openedAt: $openedAt, ')
- ..write('closedAt: $closedAt, ')
..write('version: $version, ')
..write('rowid: $rowid')
..write(')'))
@@ -1783,18 +1686,16 @@ class $ClosedTabsTable extends ClosedTabs
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
- static const VerificationMeta _paymentMethodMeta = const VerificationMeta(
- 'paymentMethod',
- );
@override
- late final GeneratedColumn paymentMethod = GeneratedColumn(
+ late final GeneratedColumnWithTypeConverter
+ paymentMethod = GeneratedColumn(
'payment_method',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('cash'),
- );
+ ).withConverter($ClosedTabsTable.$converterpaymentMethod);
@override
List get $columns => [
id,
@@ -1850,15 +1751,6 @@ class $ClosedTabsTable extends ClosedTabs
} else if (isInserting) {
context.missing(_closedAtMeta);
}
- if (data.containsKey('payment_method')) {
- context.handle(
- _paymentMethodMeta,
- paymentMethod.isAcceptableOrUnknown(
- data['payment_method']!,
- _paymentMethodMeta,
- ),
- );
- }
return context;
}
@@ -1884,10 +1776,12 @@ class $ClosedTabsTable extends ClosedTabs
DriftSqlType.dateTime,
data['${effectivePrefix}closed_at'],
)!,
- paymentMethod: attachedDatabase.typeMapping.read(
- DriftSqlType.string,
- data['${effectivePrefix}payment_method'],
- )!,
+ paymentMethod: $ClosedTabsTable.$converterpaymentMethod.fromSql(
+ attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}payment_method'],
+ )!,
+ ),
);
}
@@ -1895,6 +1789,9 @@ class $ClosedTabsTable extends ClosedTabs
$ClosedTabsTable createAlias(String alias) {
return $ClosedTabsTable(attachedDatabase, alias);
}
+
+ static TypeConverter $converterpaymentMethod =
+ const PaymentMethodConverter();
}
class ClosedTabsRow extends DataClass implements Insertable {
@@ -1902,7 +1799,7 @@ class ClosedTabsRow extends DataClass implements Insertable {
final String originalTabId;
final String customerName;
final DateTime closedAt;
- final String paymentMethod;
+ final PaymentMethod paymentMethod;
const ClosedTabsRow({
required this.id,
required this.originalTabId,
@@ -1917,7 +1814,11 @@ class ClosedTabsRow extends DataClass implements Insertable {
map['original_tab_id'] = Variable(originalTabId);
map['customer_name'] = Variable(customerName);
map['closed_at'] = Variable(closedAt);
- map['payment_method'] = Variable(paymentMethod);
+ {
+ map['payment_method'] = Variable(
+ $ClosedTabsTable.$converterpaymentMethod.toSql(paymentMethod),
+ );
+ }
return map;
}
@@ -1941,7 +1842,7 @@ class ClosedTabsRow extends DataClass implements Insertable {
originalTabId: serializer.fromJson(json['originalTabId']),
customerName: serializer.fromJson(json['customerName']),
closedAt: serializer.fromJson(json['closedAt']),
- paymentMethod: serializer.fromJson(json['paymentMethod']),
+ paymentMethod: serializer.fromJson(json['paymentMethod']),
);
}
@override
@@ -1952,7 +1853,7 @@ class ClosedTabsRow extends DataClass implements Insertable {
'originalTabId': serializer.toJson(originalTabId),
'customerName': serializer.toJson(customerName),
'closedAt': serializer.toJson(closedAt),
- 'paymentMethod': serializer.toJson(paymentMethod),
+ 'paymentMethod': serializer.toJson(paymentMethod),
};
}
@@ -1961,7 +1862,7 @@ class ClosedTabsRow extends DataClass implements Insertable {
String? originalTabId,
String? customerName,
DateTime? closedAt,
- String? paymentMethod,
+ PaymentMethod? paymentMethod,
}) => ClosedTabsRow(
id: id ?? this.id,
originalTabId: originalTabId ?? this.originalTabId,
@@ -2016,7 +1917,7 @@ class ClosedTabsCompanion extends UpdateCompanion {
final Value originalTabId;
final Value customerName;
final Value closedAt;
- final Value paymentMethod;
+ final Value paymentMethod;
final Value rowid;
const ClosedTabsCompanion({
this.id = const Value.absent(),
@@ -2060,7 +1961,7 @@ class ClosedTabsCompanion extends UpdateCompanion {
Value? originalTabId,
Value? customerName,
Value? closedAt,
- Value? paymentMethod,
+ Value? paymentMethod,
Value? rowid,
}) {
return ClosedTabsCompanion(
@@ -2089,7 +1990,9 @@ class ClosedTabsCompanion extends UpdateCompanion {
map['closed_at'] = Variable(closedAt.value);
}
if (paymentMethod.present) {
- map['payment_method'] = Variable(paymentMethod.value);
+ map['payment_method'] = Variable(
+ $ClosedTabsTable.$converterpaymentMethod.toSql(paymentMethod.value),
+ );
}
if (rowid.present) {
map['rowid'] = Variable(rowid.value);
@@ -3532,15 +3435,17 @@ class $ProcessedRemoteCommandsTable extends ProcessedRemoteCommands
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
- static const VerificationMeta _resultMeta = const VerificationMeta('result');
@override
- late final GeneratedColumn result = GeneratedColumn(
- 'result',
- aliasedName,
- false,
- type: DriftSqlType.string,
- requiredDuringInsert: true,
- );
+ late final GeneratedColumnWithTypeConverter result =
+ GeneratedColumn(
+ 'result',
+ aliasedName,
+ false,
+ type: DriftSqlType.string,
+ requiredDuringInsert: true,
+ ).withConverter(
+ $ProcessedRemoteCommandsTable.$converterresult,
+ );
@override
List get $columns => [
commandId,
@@ -3590,14 +3495,6 @@ class $ProcessedRemoteCommandsTable extends ProcessedRemoteCommands
} else if (isInserting) {
context.missing(_processedAtMeta);
}
- if (data.containsKey('result')) {
- context.handle(
- _resultMeta,
- result.isAcceptableOrUnknown(data['result']!, _resultMeta),
- );
- } else if (isInserting) {
- context.missing(_resultMeta);
- }
return context;
}
@@ -3622,10 +3519,12 @@ class $ProcessedRemoteCommandsTable extends ProcessedRemoteCommands
DriftSqlType.dateTime,
data['${effectivePrefix}processed_at'],
)!,
- result: attachedDatabase.typeMapping.read(
- DriftSqlType.string,
- data['${effectivePrefix}result'],
- )!,
+ result: $ProcessedRemoteCommandsTable.$converterresult.fromSql(
+ attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}result'],
+ )!,
+ ),
);
}
@@ -3633,6 +3532,9 @@ class $ProcessedRemoteCommandsTable extends ProcessedRemoteCommands
$ProcessedRemoteCommandsTable createAlias(String alias) {
return $ProcessedRemoteCommandsTable(attachedDatabase, alias);
}
+
+ static JsonTypeConverter2 $converterresult =
+ const EnumNameConverter(CommandResult.values);
}
class ProcessedRemoteCommandRow extends DataClass
@@ -3640,7 +3542,7 @@ class ProcessedRemoteCommandRow extends DataClass
final String commandId;
final String commandType;
final DateTime processedAt;
- final String result;
+ final CommandResult result;
const ProcessedRemoteCommandRow({
required this.commandId,
required this.commandType,
@@ -3653,7 +3555,11 @@ class ProcessedRemoteCommandRow extends DataClass
map['command_id'] = Variable(commandId);
map['command_type'] = Variable(commandType);
map['processed_at'] = Variable(processedAt);
- map['result'] = Variable(result);
+ {
+ map['result'] = Variable(
+ $ProcessedRemoteCommandsTable.$converterresult.toSql(result),
+ );
+ }
return map;
}
@@ -3675,7 +3581,9 @@ class ProcessedRemoteCommandRow extends DataClass
commandId: serializer.fromJson(json['commandId']),
commandType: serializer.fromJson(json['commandType']),
processedAt: serializer.fromJson(json['processedAt']),
- result: serializer.fromJson(json['result']),
+ result: $ProcessedRemoteCommandsTable.$converterresult.fromJson(
+ serializer.fromJson(json['result']),
+ ),
);
}
@override
@@ -3685,7 +3593,9 @@ class ProcessedRemoteCommandRow extends DataClass
'commandId': serializer.toJson(commandId),
'commandType': serializer.toJson(commandType),
'processedAt': serializer.toJson(processedAt),
- 'result': serializer.toJson(result),
+ 'result': serializer.toJson(
+ $ProcessedRemoteCommandsTable.$converterresult.toJson(result),
+ ),
};
}
@@ -3693,7 +3603,7 @@ class ProcessedRemoteCommandRow extends DataClass
String? commandId,
String? commandType,
DateTime? processedAt,
- String? result,
+ CommandResult? result,
}) => ProcessedRemoteCommandRow(
commandId: commandId ?? this.commandId,
commandType: commandType ?? this.commandType,
@@ -3743,7 +3653,7 @@ class ProcessedRemoteCommandsCompanion
final Value commandId;
final Value commandType;
final Value processedAt;
- final Value result;
+ final Value result;
final Value rowid;
const ProcessedRemoteCommandsCompanion({
this.commandId = const Value.absent(),
@@ -3756,7 +3666,7 @@ class ProcessedRemoteCommandsCompanion
required String commandId,
required String commandType,
required DateTime processedAt,
- required String result,
+ required CommandResult result,
this.rowid = const Value.absent(),
}) : commandId = Value(commandId),
commandType = Value(commandType),
@@ -3782,7 +3692,7 @@ class ProcessedRemoteCommandsCompanion
Value? commandId,
Value? commandType,
Value? processedAt,
- Value? result,
+ Value? result,
Value? rowid,
}) {
return ProcessedRemoteCommandsCompanion(
@@ -3807,7 +3717,9 @@ class ProcessedRemoteCommandsCompanion
map['processed_at'] = Variable(processedAt.value);
}
if (result.present) {
- map['result'] = Variable(result.value);
+ map['result'] = Variable(
+ $ProcessedRemoteCommandsTable.$converterresult.toSql(result.value),
+ );
}
if (rowid.present) {
map['rowid'] = Variable(rowid.value);
@@ -4245,9 +4157,7 @@ typedef $$BarTabsTableCreateCompanionBuilder =
BarTabsCompanion Function({
required String id,
required String customerName,
- Value status,
required DateTime openedAt,
- Value closedAt,
Value version,
Value rowid,
});
@@ -4255,9 +4165,7 @@ typedef $$BarTabsTableUpdateCompanionBuilder =
BarTabsCompanion Function({
Value id,
Value customerName,
- Value status,
Value openedAt,
- Value closedAt,
Value version,
Value rowid,
});
@@ -4304,21 +4212,11 @@ class $$BarTabsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
- ColumnFilters get status => $composableBuilder(
- column: $table.status,
- builder: (column) => ColumnFilters(column),
- );
-
ColumnFilters get openedAt => $composableBuilder(
column: $table.openedAt,
builder: (column) => ColumnFilters(column),
);
- ColumnFilters get closedAt => $composableBuilder(
- column: $table.closedAt,
- builder: (column) => ColumnFilters(column),
- );
-
ColumnFilters get version => $composableBuilder(
column: $table.version,
builder: (column) => ColumnFilters(column),
@@ -4369,21 +4267,11 @@ class $$BarTabsTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
- ColumnOrderings get status => $composableBuilder(
- column: $table.status,
- builder: (column) => ColumnOrderings(column),
- );
-
ColumnOrderings get openedAt => $composableBuilder(
column: $table.openedAt,
builder: (column) => ColumnOrderings(column),
);
- ColumnOrderings get closedAt => $composableBuilder(
- column: $table.closedAt,
- builder: (column) => ColumnOrderings(column),
- );
-
ColumnOrderings get version => $composableBuilder(
column: $table.version,
builder: (column) => ColumnOrderings(column),
@@ -4407,15 +4295,9 @@ class $$BarTabsTableAnnotationComposer
builder: (column) => column,
);
- GeneratedColumn get status =>
- $composableBuilder(column: $table.status, builder: (column) => column);
-
GeneratedColumn get openedAt =>
$composableBuilder(column: $table.openedAt, builder: (column) => column);
- GeneratedColumn get closedAt =>
- $composableBuilder(column: $table.closedAt, builder: (column) => column);
-
GeneratedColumn get version =>
$composableBuilder(column: $table.version, builder: (column) => column);
@@ -4475,17 +4357,13 @@ class $$BarTabsTableTableManager
({
Value id = const Value.absent(),
Value customerName = const Value.absent(),
- Value status = const Value.absent(),
Value openedAt = const Value.absent(),
- Value closedAt = const Value.absent(),
Value version = const Value.absent(),
Value rowid = const Value.absent(),
}) => BarTabsCompanion(
id: id,
customerName: customerName,
- status: status,
openedAt: openedAt,
- closedAt: closedAt,
version: version,
rowid: rowid,
),
@@ -4493,17 +4371,13 @@ class $$BarTabsTableTableManager
({
required String id,
required String customerName,
- Value status = const Value.absent(),
required DateTime openedAt,
- Value closedAt = const Value.absent(),
Value version = const Value.absent(),
Value rowid = const Value.absent(),
}) => BarTabsCompanion.insert(
id: id,
customerName: customerName,
- status: status,
openedAt: openedAt,
- closedAt: closedAt,
version: version,
rowid: rowid,
),
@@ -5430,7 +5304,7 @@ typedef $$ClosedTabsTableCreateCompanionBuilder =
required String originalTabId,
required String customerName,
required DateTime closedAt,
- Value paymentMethod,
+ Value paymentMethod,
Value rowid,
});
typedef $$ClosedTabsTableUpdateCompanionBuilder =
@@ -5439,7 +5313,7 @@ typedef $$ClosedTabsTableUpdateCompanionBuilder =
Value originalTabId,
Value customerName,
Value closedAt,
- Value paymentMethod,
+ Value paymentMethod,
Value rowid,
});
@@ -5472,9 +5346,10 @@ class $$ClosedTabsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
- ColumnFilters get paymentMethod => $composableBuilder(
+ ColumnWithTypeConverterFilters
+ get paymentMethod => $composableBuilder(
column: $table.paymentMethod,
- builder: (column) => ColumnFilters(column),
+ builder: (column) => ColumnWithTypeConverterFilters(column),
);
}
@@ -5538,10 +5413,11 @@ class $$ClosedTabsTableAnnotationComposer
GeneratedColumn get closedAt =>
$composableBuilder(column: $table.closedAt, builder: (column) => column);
- GeneratedColumn get paymentMethod => $composableBuilder(
- column: $table.paymentMethod,
- builder: (column) => column,
- );
+ GeneratedColumnWithTypeConverter get paymentMethod =>
+ $composableBuilder(
+ column: $table.paymentMethod,
+ builder: (column) => column,
+ );
}
class $$ClosedTabsTableTableManager
@@ -5579,7 +5455,7 @@ class $$ClosedTabsTableTableManager
Value originalTabId = const Value.absent(),
Value customerName = const Value.absent(),
Value closedAt = const Value.absent(),
- Value paymentMethod = const Value.absent(),
+ Value paymentMethod = const Value.absent(),
Value rowid = const Value.absent(),
}) => ClosedTabsCompanion(
id: id,
@@ -5595,7 +5471,7 @@ class $$ClosedTabsTableTableManager
required String originalTabId,
required String customerName,
required DateTime closedAt,
- Value paymentMethod = const Value.absent(),
+ Value paymentMethod = const Value.absent(),
Value rowid = const Value.absent(),
}) => ClosedTabsCompanion.insert(
id: id,
@@ -6355,7 +6231,7 @@ typedef $$ProcessedRemoteCommandsTableCreateCompanionBuilder =
required String commandId,
required String commandType,
required DateTime processedAt,
- required String result,
+ required CommandResult result,
Value rowid,
});
typedef $$ProcessedRemoteCommandsTableUpdateCompanionBuilder =
@@ -6363,7 +6239,7 @@ typedef $$ProcessedRemoteCommandsTableUpdateCompanionBuilder =
Value commandId,
Value commandType,
Value processedAt,
- Value result,
+ Value result,
Value rowid,
});
@@ -6391,9 +6267,10 @@ class $$ProcessedRemoteCommandsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
- ColumnFilters get result => $composableBuilder(
+ ColumnWithTypeConverterFilters