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 + get result => $composableBuilder( column: $table.result, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnWithTypeConverterFilters(column), ); } @@ -6449,7 +6326,7 @@ class $$ProcessedRemoteCommandsTableAnnotationComposer builder: (column) => column, ); - GeneratedColumn get result => + GeneratedColumnWithTypeConverter get result => $composableBuilder(column: $table.result, builder: (column) => column); } @@ -6502,7 +6379,7 @@ class $$ProcessedRemoteCommandsTableTableManager Value commandId = const Value.absent(), Value commandType = const Value.absent(), Value processedAt = const Value.absent(), - Value result = const Value.absent(), + Value result = const Value.absent(), Value rowid = const Value.absent(), }) => ProcessedRemoteCommandsCompanion( commandId: commandId, @@ -6516,7 +6393,7 @@ class $$ProcessedRemoteCommandsTableTableManager required String commandId, required String commandType, required DateTime processedAt, - required String result, + required CommandResult result, Value rowid = const Value.absent(), }) => ProcessedRemoteCommandsCompanion.insert( commandId: commandId, diff --git a/lib/database/stock_queries.dart b/lib/database/stock_queries.dart new file mode 100644 index 0000000..86d9a3d --- /dev/null +++ b/lib/database/stock_queries.dart @@ -0,0 +1,70 @@ +import 'package:drift/drift.dart'; + +import 'app_database.dart'; + +class InsufficientStockException implements Exception { + const InsufficientStockException(); + + @override + String toString() => 'Not enough stock'; +} + +class ProductNotFoundException implements Exception { + final String productId; + + const ProductNotFoundException(this.productId); + + @override + String toString() => 'Product not found: $productId'; +} + +/// Atomic stock updates shared by the product and tab services, so they can +/// run inside either one's transaction. +extension StockQueries on AppDatabase { + Future decreaseStock(String productId, int amount) async { + _validateStockAmount(amount); + + final updatedRows = + await (update(products)..where( + (row) => + row.id.equals(productId) & + row.stockQuantity.isBiggerOrEqualValue(amount), + )) + .write( + ProductsCompanion.custom( + stockQuantity: products.stockQuantity - Variable(amount), + ), + ); + + if (updatedRows == 1) return; + + final product = await (select( + products, + )..where((row) => row.id.equals(productId))).getSingleOrNull(); + + if (product == null) throw ProductNotFoundException(productId); + + throw const InsufficientStockException(); + } + + Future increaseStock(String productId, int amount) async { + _validateStockAmount(amount); + + final updatedRows = + await (update( + products, + )..where((row) => row.id.equals(productId))).write( + ProductsCompanion.custom( + stockQuantity: products.stockQuantity + Variable(amount), + ), + ); + + if (updatedRows != 1) throw ProductNotFoundException(productId); + } + + void _validateStockAmount(int amount) { + if (amount <= 0) { + throw ArgumentError.value(amount, 'amount', 'Must be greater than zero'); + } + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bb6b247..71dfcaa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,6 +1,6 @@ { "@@locale": "en", - "appTitle": "KoolTab", + "appTitle": "KoolTap", "barTabs": "Bar Tabs", "products": "Products", "addProduct": "Add product", @@ -114,6 +114,7 @@ "pinsDidNotMatch": "PINs didn’t match. Try again.", "somethingWentWrong": "Something went wrong.", "incorrectPin": "Incorrect PIN.", + "tooManyPinAttempts": "Too many attempts. Try again later.", "removed": "Removed", "enterPrice": "Enter a price.", "enterValidPrice": "Enter a valid price.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5b4d460..3669d34 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -101,7 +101,7 @@ abstract class AppLocalizations { /// No description provided for @appTitle. /// /// In en, this message translates to: - /// **'KoolTab'** + /// **'KoolTap'** String get appTitle; /// No description provided for @barTabs. @@ -746,6 +746,12 @@ abstract class AppLocalizations { /// **'Incorrect PIN.'** String get incorrectPin; + /// No description provided for @tooManyPinAttempts. + /// + /// In en, this message translates to: + /// **'Too many attempts. Try again later.'** + String get tooManyPinAttempts; + /// No description provided for @removed. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 30b1b17..c5c8ceb 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get appTitle => 'KoolTab'; + String get appTitle => 'KoolTap'; @override String get barTabs => 'Bar Tabs'; @@ -361,6 +361,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get incorrectPin => 'Incorrect PIN.'; + @override + String get tooManyPinAttempts => 'Too many attempts. Try again later.'; + @override String get removed => 'Removed'; diff --git a/lib/l10n/app_localizations_helpers.dart b/lib/l10n/app_localizations_helpers.dart index 64505aa..0bde941 100644 --- a/lib/l10n/app_localizations_helpers.dart +++ b/lib/l10n/app_localizations_helpers.dart @@ -1,3 +1,4 @@ +import '../models/app_error.dart'; import 'app_localizations.dart'; extension AppLocalizationsHelpers on AppLocalizations { @@ -15,32 +16,37 @@ extension AppLocalizationsHelpers on AppLocalizations { }; } - String localizedError(String? message) { + String errorText(AppError? error) { + return switch (error) { + null => genericError, + AppError.loadBar => couldNotLoadBar, + AppError.createTab => couldNotCreateTabMessage, + AppError.outOfStock => productOutOfStock, + AppError.addProductToTab => couldNotAddProductToTab, + AppError.updateQuantity => couldNotUpdateQuantity, + AppError.closeTab => couldNotCloseTab, + AppError.renameTab => couldNotRenameTab, + AppError.deleteTab => couldNotDeleteTab, + AppError.loadHistory => couldNotLoadHistory, + AppError.loadMoreTabs => couldNotLoadMoreTabs, + AppError.loadProducts => couldNotLoadProducts, + AppError.addProduct => couldNotAddProduct, + AppError.updateProduct => couldNotUpdateProduct, + AppError.deleteProduct => couldNotDeleteProduct, + AppError.checkPinStatus => couldNotCheckPinStatus, + AppError.savePin => couldNotSavePin, + AppError.incorrectPin => incorrectPin, + AppError.verifyPin => couldNotVerifyPin, + AppError.currentPinIncorrect => currentPinIncorrect, + AppError.tooManyPinAttempts => tooManyPinAttempts, + AppError.loadSettings => couldNotLoadSettings, + AppError.saveSettings => couldNotSaveSettings, + }; + } + + /// Update messages still come from the OTA plugin as plain strings. + String _updateMessage(String? message) { return switch (message) { - 'Could not load bar screen.' => couldNotLoadBar, - 'Could not create tab.' => couldNotCreateTabMessage, - 'Product is out of stock.' => productOutOfStock, - 'Could not add product to tab.' => couldNotAddProductToTab, - 'Could not update item quantity.' => couldNotUpdateQuantity, - 'Could not close tab.' => couldNotCloseTab, - 'Could not rename tab.' => couldNotRenameTab, - 'Could not delete tab.' => couldNotDeleteTab, - 'Could not reload tabs.' => couldNotReloadTabs, - 'Could not load tab history.' => couldNotLoadHistory, - 'Could not load more tabs.' => couldNotLoadMoreTabs, - 'Could not load products.' => couldNotLoadProducts, - 'Could not add product.' => couldNotAddProduct, - 'Could not update product.' => couldNotUpdateProduct, - 'Could not check PIN status.' => couldNotCheckPinStatus, - 'Could not save PIN.' => couldNotSavePin, - 'Incorrect PIN.' => incorrectPin, - 'Could not verify PIN.' => couldNotVerifyPin, - 'Current PIN is incorrect.' => currentPinIncorrect, - 'Could not load settings.' => couldNotLoadSettings, - 'Could not save settings.' => couldNotSaveSettings, - 'Could not load product.' => couldNotLoadProduct, - 'Could not save product.' => couldNotSaveProduct, - 'Could not delete product.' => couldNotDeleteProduct, 'Update failed' => updateFailed, 'Download failed' => downloadFailed, 'Installation failed' => installationFailed, @@ -76,7 +82,7 @@ extension AppLocalizationsHelpers on AppLocalizations { } if (message == 'Update already in progress') return updateAlreadyInProgress; - return localizedError(message); + return _updateMessage(message); } String devAction(String? message) { diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ecb2463..a8083b4 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -9,7 +9,7 @@ class AppLocalizationsNl extends AppLocalizations { AppLocalizationsNl([String locale = 'nl']) : super(locale); @override - String get appTitle => 'KoolTab'; + String get appTitle => 'KoolTap'; @override String get barTabs => 'Poefs'; @@ -362,6 +362,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get incorrectPin => 'Onjuiste PIN.'; + @override + String get tooManyPinAttempts => + 'Te veel pogingen. Probeer het later opnieuw.'; + @override String get removed => 'Verwijderd'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 611c888..ccd34f4 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1,6 +1,6 @@ { "@@locale": "nl", - "appTitle": "KoolTab", + "appTitle": "KoolTap", "barTabs": "Poefs", "products": "Producten", "addProduct": "Product toevoegen", @@ -114,6 +114,7 @@ "pinsDidNotMatch": "PINs kwamen niet overeen. Probeer het opnieuw.", "somethingWentWrong": "Er is iets misgegaan.", "incorrectPin": "Onjuiste PIN.", + "tooManyPinAttempts": "Te veel pogingen. Probeer het later opnieuw.", "removed": "Verwijderd", "enterPrice": "Voer een prijs in.", "enterValidPrice": "Voer een geldige prijs in.", diff --git a/lib/main.dart b/lib/main.dart index 364ef3f..f535855 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,15 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; -import 'package:kooltab2/services/pin_lock_service.dart'; -import 'package:kooltab2/services/remote_sync_service.dart'; -import 'package:kooltab2/services/settings_service.dart'; -import 'package:kooltab2/viewmodels/dev_menu_view_model.dart'; -import 'package:kooltab2/viewmodels/history_view_model.dart'; -import 'package:kooltab2/viewmodels/inventory_view_model.dart'; -import 'package:kooltab2/viewmodels/pin_lock_view_model.dart'; -import 'package:kooltab2/viewmodels/registration_view_model.dart'; -import 'package:kooltab2/viewmodels/settings_view_model.dart'; import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -21,15 +12,45 @@ import 'app/router.dart'; import 'database/app_database.dart'; import 'services/admin_pin_service.dart'; import 'services/bar_tab_service.dart'; +import 'services/kooltap_api_client.dart'; +import 'services/pin_lock_service.dart'; import 'services/product_service.dart'; -import 'viewmodels/bar_screen_view_model.dart'; +import 'services/remote_sync_service.dart'; +import 'services/settings_service.dart'; import 'viewmodels/admin_pin_view_model.dart'; +import 'viewmodels/bar_screen_view_model.dart'; +import 'viewmodels/dev_menu_view_model.dart'; +import 'viewmodels/history_view_model.dart'; +import 'viewmodels/inventory_view_model.dart'; +import 'viewmodels/pin_lock_view_model.dart'; import 'viewmodels/product_list_view_model.dart'; +import 'viewmodels/registration_view_model.dart'; import 'viewmodels/remote_sync_view_model.dart'; +import 'viewmodels/settings_view_model.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // Initialized first so errors during startup are reported too. + await SentryFlutter.init((options) { + options.dsn = + 'https://2c31c1baf3ee495288ad08a56d7baf0e@glitchtip.brammie15.dev/1'; + options.tracesSampleRate = kReleaseMode ? 0.1 : 1.0; + options.enableAutoSessionTracking = false; + options.attachViewHierarchy = true; + options.enableLogs = true; + options.attachThreads = true; + + // Screens show customer names; replays mask all text and images. + options.attachScreenshot = false; + options.privacy.maskAllText = true; + options.privacy.maskAllImages = true; + options.replay.onErrorSampleRate = 1.0; + options.replay.sessionSampleRate = 0.05; + }, appRunner: _runApp); +} + +Future _runApp() async { await initializeDateFormatting('nl_BE', null); Intl.defaultLocale = 'nl_BE'; @@ -39,125 +60,95 @@ Future main() async { ]); final database = AppDatabase(); - - final pinLockViewModel = PinLockViewModel( - pinLockService: PinLockService(), - settingsService: DriftSettingsService(database: database), - ); - await pinLockViewModel.ensureLoaded(); - - final adminPinViewModel = AdminPinViewModel( - adminPinService: AdminPinService(), - ); - await adminPinViewModel.ensureLoaded(); + final productService = DriftProductService(database: database); + final barTabService = DriftBarTabService(database: database); + final settingsService = DriftSettingsService(database: database); + final pinLockService = PinLockService(); + final apiClient = KoolTapApiClient(); final remoteSyncService = RemoteSyncService( database: database, - barTabService: DriftBarTabService(database: database), + barTabService: barTabService, + apiClient: apiClient, ); + final pinLockViewModel = PinLockViewModel( + pinLockService: pinLockService, + settingsService: settingsService, + ); + final adminPinViewModel = AdminPinViewModel( + adminPinService: AdminPinService(), + ); final remoteSyncViewModel = RemoteSyncViewModel( syncService: remoteSyncService, ); - final isConfiguredNotifier = ValueNotifier(await remoteSyncService.isConfigured); - - remoteSyncViewModel.addListener(() { - isConfiguredNotifier.value = remoteSyncViewModel.isConfigured; - }); - - final registrationViewModel = RegistrationViewModel( - syncService: remoteSyncService, - ); + // The router's first redirect depends on these. + await Future.wait([ + pinLockViewModel.ensureLoaded(), + adminPinViewModel.ensureLoaded(), + remoteSyncViewModel.init(), + ]); final appRouter = createAppRouter( - pinLockViewModel, + pinLockViewModel: pinLockViewModel, adminPinViewModel: adminPinViewModel, - isConfiguredNotifier: isConfiguredNotifier, + remoteSyncViewModel: remoteSyncViewModel, ); - await SentryFlutter.init( - (options) { - options.dsn = - 'https://2c31c1baf3ee495288ad08a56d7baf0e@glitchtip.brammie15.dev/1'; - options.tracesSampleRate = kReleaseMode ? 0.1 : 1.0; - options.enableAutoSessionTracking = false; - options.attachScreenshot = true; - options.attachViewHierarchy = true; - options.enableLogs = true; - options.attachThreads = true; - - options.replay.onErrorSampleRate = 1.0; - options.replay.sessionSampleRate = 0.05; - }, - appRunner: () => runApp( - MultiProvider( - providers: [ - Provider.value(value: database), - Provider( - create: (context) => - DriftProductService(database: context.read()), + runApp( + MultiProvider( + providers: [ + Provider.value(value: database), + Provider.value(value: productService), + Provider.value(value: barTabService), + Provider.value(value: settingsService), + Provider( + create: (_) => remoteSyncService, + dispose: (_, service) => service.dispose(), + ), + ChangeNotifierProvider.value( + value: remoteSyncViewModel, + ), + ChangeNotifierProvider( + create: (_) => RegistrationViewModel( + apiClient: apiClient, + syncService: remoteSyncService, ), - Provider( - create: (context) => - DriftBarTabService(database: context.read()), + ), + ChangeNotifierProvider( + create: (_) => InventoryViewModel(productService: productService), + ), + ChangeNotifierProvider( + create: (context) => ProductListViewModel( + productService: productService, + inventory: context.read(), ), - Provider( - create: (context) => - DriftSettingsService(database: context.read()), + ), + ChangeNotifierProvider( + create: (_) => BarScreenViewModel(barTabService: barTabService), + ), + ChangeNotifierProvider( + create: (_) => HistoryViewModel(barTabService: barTabService), + ), + ChangeNotifierProvider.value(value: pinLockViewModel), + ChangeNotifierProvider.value( + value: adminPinViewModel, + ), + ChangeNotifierProvider( + create: (_) => SettingsViewModel(settingsService: settingsService), + ), + ChangeNotifierProvider( + create: (_) => DevMenuViewModel( + database: database, + productService: productService, + barTabService: barTabService, + settingsService: settingsService, + pinLockService: pinLockService, ), - Provider.value(value: remoteSyncService), - ChangeNotifierProvider.value( - value: remoteSyncViewModel, - ), - ChangeNotifierProvider.value( - value: registrationViewModel, - ), - ChangeNotifierProvider( - create: (context) => InventoryViewModel( - productService: context.read(), - )..load(), - ), - ChangeNotifierProvider( - create: (context) => ProductListViewModel( - productService: context.read(), - inventory: context.read(), - ), - ), - ChangeNotifierProvider( - create: (context) => BarScreenViewModel( - barTabService: context.read(), - inventory: context.read(), - remoteSyncViewModel: context.read(), - ), - ), - ChangeNotifierProvider( - create: (context) => - HistoryViewModel(barTabService: context.read()), - ), - ChangeNotifierProvider.value( - value: pinLockViewModel, - ), - ChangeNotifierProvider.value( - value: adminPinViewModel, - ), - ChangeNotifierProvider( - create: (context) => SettingsViewModel( - settingsService: context.read(), - ), - ), - ChangeNotifierProvider( - create: (context) => DevMenuViewModel( - database: context.read(), - productService: context.read(), - barTabService: context.read(), - settingsService: context.read(), - pinLockService: PinLockService(), - ), - ), - ], - child: AppBootstrap(child: KoolTabApp(router: appRouter)), - ), + ), + ], + child: AppBootstrap(child: KoolTapApp(router: appRouter)), ), ); } diff --git a/lib/models/app_error.dart b/lib/models/app_error.dart new file mode 100644 index 0000000..c95787c --- /dev/null +++ b/lib/models/app_error.dart @@ -0,0 +1,26 @@ +/// User-facing failures. View models expose these and views translate them +/// with `AppLocalizations.errorText`, so a typo can't silently skip l10n. +enum AppError { + loadBar, + createTab, + outOfStock, + addProductToTab, + updateQuantity, + closeTab, + renameTab, + deleteTab, + loadHistory, + loadMoreTabs, + loadProducts, + addProduct, + updateProduct, + deleteProduct, + checkPinStatus, + savePin, + incorrectPin, + verifyPin, + currentPinIncorrect, + tooManyPinAttempts, + loadSettings, + saveSettings, +} diff --git a/lib/models/bar_tab.dart b/lib/models/bar_tab.dart index 544784b..87065fc 100644 --- a/lib/models/bar_tab.dart +++ b/lib/models/bar_tab.dart @@ -1,23 +1,20 @@ +import 'package:flutter/foundation.dart'; + import 'tab_item.dart'; -import '../utils/collection_utils.dart'; class BarTab { final String id; final String customerName; - final String status; final DateTime openedAt; - final DateTime? closedAt; final int version; final List items; const BarTab({ required this.id, required this.customerName, - required this.status, required this.openedAt, this.version = 0, required this.items, - this.closedAt, }); int get totalInCents { @@ -28,32 +25,17 @@ class BarTab { return items.fold(0, (total, item) => total + item.quantity); } - String get formattedTotal { - return '€${(totalInCents / 100).toStringAsFixed(2)}'; - } - - bool get isOpen => status == 'open'; - @override bool operator ==(Object other) => identical(this, other) || other is BarTab && id == other.id && customerName == other.customerName && - status == other.status && openedAt == other.openedAt && - closedAt == other.closedAt && version == other.version && listEquals(items, other.items); @override - int get hashCode => Object.hash( - id, - customerName, - status, - openedAt, - closedAt, - version, - Object.hashAll(items), - ); + int get hashCode => + Object.hash(id, customerName, openedAt, version, Object.hashAll(items)); } diff --git a/lib/models/closed_tab.dart b/lib/models/closed_tab.dart index 36718dd..16aca54 100644 --- a/lib/models/closed_tab.dart +++ b/lib/models/closed_tab.dart @@ -1,8 +1,7 @@ -import 'package:intl/intl.dart'; +import 'package:flutter/foundation.dart'; import 'closed_tab_item.dart'; import 'payment_method.dart'; -import '../utils/collection_utils.dart'; class ClosedTab { final String id; @@ -10,8 +9,8 @@ class ClosedTab { final String customerName; final DateTime closedAt; final PaymentMethod paymentMethod; - final List items; + const ClosedTab({ required this.id, required this.originalTabId, @@ -27,11 +26,6 @@ class ClosedTab { int get totalInCents => items.fold(0, (sum, item) => sum + item.lineTotalInCents); - String get formattedTotal => - NumberFormat.simpleCurrency().format(totalInCents / 100); - - String get formattedPaymentMethod => paymentMethod.label; - @override bool operator ==(Object other) => identical(this, other) || diff --git a/lib/models/payment_method.dart b/lib/models/payment_method.dart index 660d6bc..9b59934 100644 --- a/lib/models/payment_method.dart +++ b/lib/models/payment_method.dart @@ -1,16 +1,15 @@ enum PaymentMethod { - cash('cash', 'Cash', 'Cash payment'), - payconiq('payconiq', 'Payconiq', 'Payconiq QR payment'), - paymentPending('payment_pending', 'Payment pending', 'Unpaid remote close'); + cash('cash'), + payconiq('payconiq'), + paymentPending('payment_pending'); final String value; - final String label; - final String description; - const PaymentMethod(this.value, this.label, this.description); + const PaymentMethod(this.value); - static PaymentMethod fromValue(String value) => PaymentMethod.values.firstWhere( - (e) => e.value == value, + static PaymentMethod fromValue(String value) => + PaymentMethod.values.firstWhere( + (method) => method.value == value, orElse: () => PaymentMethod.cash, ); -} \ No newline at end of file +} diff --git a/lib/models/product.dart b/lib/models/product.dart index 94a8dfb..0ce3abc 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -21,12 +21,6 @@ class Product { bool get isLowStock => stockQuantity <= lowStockThreshold; - String get formattedPrice { - final euros = priceInCents / 100; - - return '€${euros.toStringAsFixed(2)}'; - } - Product copyWith({ String? id, String? name, @@ -64,13 +58,13 @@ class Product { @override int get hashCode => Object.hash( - id, - name, - category, - stockQuantity, - lowStockThreshold, - priceInCents, - imagePath, - active, - ); + id, + name, + category, + stockQuantity, + lowStockThreshold, + priceInCents, + imagePath, + active, + ); } diff --git a/lib/models/remote_command.dart b/lib/models/remote_command.dart index df8da26..57f0646 100644 --- a/lib/models/remote_command.dart +++ b/lib/models/remote_command.dart @@ -1,6 +1,25 @@ +import '../utils/app_config.dart'; + +enum CommandType { + closeTab('close_tab'); + + final String value; + + const CommandType(this.value); + + static CommandType? fromValue(String value) { + for (final type in CommandType.values) { + if (type.value == value) return type; + } + return null; + } +} + +enum CommandResult { completed, conflict, failed, expired } + class RemoteCommand { final String id; - final String type; + final String rawType; final String tabId; final int expectedVersion; final String paymentMethod; @@ -9,7 +28,7 @@ class RemoteCommand { const RemoteCommand({ required this.id, - required this.type, + required this.rawType, required this.tabId, required this.expectedVersion, required this.paymentMethod, @@ -20,7 +39,7 @@ class RemoteCommand { factory RemoteCommand.fromJson(Map json) { return RemoteCommand( id: json['id'] as String, - type: json['type'] as String, + rawType: json['type'] as String, tabId: json['tabId'] as String, expectedVersion: json['expectedVersion'] as int, paymentMethod: json['paymentMethod'] as String? ?? 'cash', @@ -29,8 +48,13 @@ class RemoteCommand { ); } + /// Null when the server sends a command this app version doesn't know. + CommandType? get type => CommandType.fromValue(rawType); + bool get isExpired { - final cutoff = DateTime.now().subtract(const Duration(hours: 24)); + final cutoff = DateTime.now().subtract( + const Duration(hours: kCommandExpiryHours), + ); return createdAt.isBefore(cutoff); } } @@ -40,24 +64,23 @@ class SyncResponse { final String? cursor; final List plannedTabNames; - SyncResponse({ + const SyncResponse({ required this.commands, this.cursor, this.plannedTabNames = const [], }); factory SyncResponse.fromJson(Map json) { - final commandsList = (json['commands'] as List?) - ?.map((e) => RemoteCommand.fromJson(e as Map)) - .toList() ?? - []; return SyncResponse( - commands: commandsList, + commands: [ + for (final command in json['commands'] as List? ?? []) + RemoteCommand.fromJson(command as Map), + ], cursor: json['cursor'] as String?, - plannedTabNames: (json['plannedTabNames'] as List?) - ?.map((e) => e as String) - .toList() ?? - [], + plannedTabNames: [ + for (final name in json['plannedTabNames'] as List? ?? []) + name as String, + ], ); } } @@ -78,12 +101,12 @@ class SnapshotTab { }); Map toJson() => { - 'id': id, - 'customerName': customerName, - 'version': version, - 'totalInCents': totalInCents, - 'itemCount': itemCount, - }; + 'id': id, + 'customerName': customerName, + 'version': version, + 'totalInCents': totalInCents, + 'itemCount': itemCount, + }; } class SyncRequest { @@ -98,15 +121,15 @@ class SyncRequest { }); Map toJson() => { - 'deviceId': deviceId, - if (cursor != null) 'cursor': cursor, - 'openTabs': openTabs.map((t) => t.toJson()).toList(), - }; + 'deviceId': deviceId, + if (cursor != null) 'cursor': cursor, + 'openTabs': openTabs.map((tab) => tab.toJson()).toList(), + }; } class CommandAck { final String commandId; - final String status; + final CommandResult status; final String? result; const CommandAck({ @@ -116,10 +139,10 @@ class CommandAck { }); Map toJson() => { - 'commandId': commandId, - 'status': status, - if (result != null) 'result': result, - }; + 'commandId': commandId, + 'status': status.name, + if (result != null) 'result': result, + }; } enum RemoteSyncEventType { @@ -127,6 +150,7 @@ enum RemoteSyncEventType { syncCompleted, syncFailed, conflict, + unauthorized, } class RemoteSyncEvent { @@ -141,4 +165,4 @@ class RemoteSyncEvent { this.requestedBy, this.message, }); -} \ No newline at end of file +} diff --git a/lib/models/tab_item.dart b/lib/models/tab_item.dart index be36f63..e2ce446 100644 --- a/lib/models/tab_item.dart +++ b/lib/models/tab_item.dart @@ -1,5 +1,6 @@ +import 'package:flutter/foundation.dart'; + import 'tab_item_purchase.dart'; -import '../utils/collection_utils.dart'; class TabItem { final String id; @@ -22,14 +23,6 @@ class TabItem { int get lineTotalInCents => quantity * unitPriceInCents; - String get formattedLineTotal { - return '€${(lineTotalInCents / 100).toStringAsFixed(2)}'; - } - - String get formattedUnitPrice { - return '€${(unitPriceInCents / 100).toStringAsFixed(2)}'; - } - @override bool operator ==(Object other) => identical(this, other) || diff --git a/lib/models/venue.dart b/lib/models/venue.dart index bfdc2dd..84e9ec0 100644 --- a/lib/models/venue.dart +++ b/lib/models/venue.dart @@ -4,8 +4,6 @@ class Venue { const Venue({required this.id, required this.name}); - factory Venue.fromJson(Map json) => Venue( - id: json['id'] as String, - name: json['name'] as String, - ); -} \ No newline at end of file + factory Venue.fromJson(Map json) => + Venue(id: json['id'] as String, name: json['name'] as String); +} diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 377d836..ab5b862 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -1,7 +1,10 @@ +import 'dart:math'; + import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; +import '../database/stock_queries.dart'; import '../models/bar_tab.dart'; import '../models/closed_tab.dart'; import '../models/closed_tab_item.dart'; @@ -9,9 +12,36 @@ import '../models/payment_method.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; import '../models/tab_item_purchase.dart'; -import 'product_service.dart'; + +class TabNotFoundException implements Exception { + final String tabId; + + const TabNotFoundException(this.tabId); + + @override + String toString() => 'Tab not found: $tabId'; +} + +class TabVersionConflictException implements Exception { + final int expectedVersion; + final int actualVersion; + + const TabVersionConflictException({ + required this.expectedVersion, + required this.actualVersion, + }); + + @override + String toString() => + 'Tab is at version $actualVersion, expected $expectedVersion'; +} abstract class BarTabService { + /// Open tabs with their visible items, re-emitted on every change. + Stream> watchOpenTabs(); + + /// With [includeRemoved], items whose purchases were all undone are kept + /// so history can show them. Future> getOpenTabs({bool includeRemoved = false}); Future getTabById(String id); @@ -21,13 +51,15 @@ abstract class BarTabService { Future renameTab({required String tabId, required String customerName}); /// Deletes an open tab and returns its items to inventory. - Future> deleteTab(String tabId); + Future deleteTab(String tabId); Future addProductToTab({ required String tabId, required Product product, }); + /// Returns the delta that was actually applied, which is smaller than + /// [delta] when it would push the quantity below zero. Future adjustTabItemQuantity({ required String tabItemId, required int delta, @@ -35,9 +67,13 @@ abstract class BarTabService { /// Archives the tab's current items into history and clears them. /// The tab itself stays open under the same customer name. + /// + /// Throws [TabVersionConflictException] when [expectedVersion] is given and + /// the tab changed since. Future closeTab( String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, + int? expectedVersion, }); Future> getClosedTabs(); @@ -84,9 +120,7 @@ class DriftBarTabService implements BarTabService { return BarTab( id: row.id, customerName: row.customerName, - status: row.status, openedAt: row.openedAt, - closedAt: row.closedAt, version: row.version, items: items, ); @@ -105,222 +139,133 @@ class DriftBarTabService implements BarTabService { ); } - Future> _getItemsForTab(String tabId) async { - final query = database.select(database.tabItems) - ..where((item) => item.tabId.equals(tabId)) - ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]); + /// Tabs, items and purchases in a single query, so loading N tabs doesn't + /// cost N * items extra round trips. + JoinedSelectStatement _tabsQuery({String? tabId}) { + final query = database.select(database.barTabs).join([ + leftOuterJoin( + database.tabItems, + database.tabItems.tabId.equalsExp(database.barTabs.id), + ), + leftOuterJoin( + database.tabItemPurchases, + database.tabItemPurchases.tabItemId.equalsExp(database.tabItems.id), + ), + ]); - final rows = await query.get(); + if (tabId != null) query.where(database.barTabs.id.equals(tabId)); - final items = []; + query.orderBy([ + OrderingTerm.desc(database.barTabs.openedAt), + OrderingTerm.asc(database.tabItems.createdAt), + OrderingTerm.asc(database.tabItemPurchases.purchasedAt), + OrderingTerm.asc(database.tabItemPurchases.id), + ]); + + return query; + } + + List _groupTabs( + List rows, { + required bool includeRemoved, + }) { + final tabRows = {}; + final itemRowsByTab = >{}; + final purchasesByItem = >{}; for (final row in rows) { - if (row.quantity <= 0) continue; + final tab = row.readTable(database.barTabs); + tabRows[tab.id] = tab; + final itemRows = itemRowsByTab.putIfAbsent(tab.id, () => {}); - final purchases = await _getPurchaseRecords(row); - items.add(_mapItemRow(row, purchases)); + final item = row.readTableOrNull(database.tabItems); + if (item == null) continue; + + itemRows[item.id] = item; + final purchases = purchasesByItem.putIfAbsent(item.id, () => []); + + final purchase = row.readTableOrNull(database.tabItemPurchases); + if (purchase != null) purchases.add(_mapPurchaseRow(purchase)); } - return items; - } - - Future> _getItemsForHistory(String tabId) async { - final query = database.select(database.tabItems) - ..where((item) => item.tabId.equals(tabId)) - ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]); - - final rows = await query.get(); - final items = []; - - for (final row in rows) { - final purchases = await _getPurchaseRecords(row); - if (purchases.isEmpty) continue; - - items.add(_mapItemRow(row, purchases)); - } - - return items; - } - - Future> _getPurchaseRows(String tabItemId) async { - final query = database.select(database.tabItemPurchases) - ..where((purchase) => purchase.tabItemId.equals(tabItemId)) - ..orderBy([ - (purchase) => OrderingTerm.asc(purchase.purchasedAt), - (purchase) => OrderingTerm.asc(purchase.id), - ]); - - return query.get(); - } - - Future> _getPurchaseRecords(TabItemRow item) async { - final rows = await _getPurchaseRows(item.id); - final purchases = rows.map(_mapPurchaseRow).toList(); - final activeCount = purchases - .where((purchase) => !purchase.isRemoved) - .length; - final missingActivePurchases = item.quantity - activeCount; - - if (missingActivePurchases <= 0) return purchases; - return [ - ...purchases, - for (var index = 0; index < missingActivePurchases; index++) - TabItemPurchase( - id: 'legacy-${item.id}-$index', - tabItemId: item.id, - purchasedAt: item.createdAt, - ), + for (final tab in tabRows.values) + _mapTabRow(tab, [ + for (final item in itemRowsByTab[tab.id]!.values) + if (includeRemoved + ? purchasesByItem[item.id]!.isNotEmpty + : item.quantity > 0) + _mapItemRow(item, purchasesByItem[item.id]!), + ]), ]; } - Future> _ensurePurchaseRows(TabItemRow item) async { - final rows = await _getPurchaseRows(item.id); - final activeCount = rows - .where((purchase) => purchase.removedAt == null) - .length; - final missingActivePurchases = item.quantity - activeCount; - - for (var index = 0; index < missingActivePurchases; index++) { - await _recordPurchase(tabItemId: item.id, purchasedAt: item.createdAt); - } - - if (missingActivePurchases <= 0) return rows; - - return _getPurchaseRows(item.id); + Future _getTabRow(String tabId) { + return (database.select( + database.barTabs, + )..where((tab) => tab.id.equals(tabId))).getSingleOrNull(); } - Future _recordPurchase({ - required String tabItemId, - DateTime? purchasedAt, - }) async { + Future _recordPurchase(String tabItemId) async { await database .into(database.tabItemPurchases) .insert( TabItemPurchasesCompanion.insert( id: _uuid.v4(), tabItemId: tabItemId, - purchasedAt: purchasedAt ?? DateTime.now(), + purchasedAt: DateTime.now(), ), ); } - Future _decreaseProductStock(String productId, int amount) async { - if (amount <= 0) { - throw ArgumentError.value(amount, 'amount', 'Must be greater than zero'); - } + /// Marks the most recent active purchases as removed, keeping them in the + /// audit trail. + Future _removeLatestPurchases(String tabItemId, int count) async { + final latest = + await (database.select(database.tabItemPurchases) + ..where( + (purchase) => + purchase.tabItemId.equals(tabItemId) & + purchase.removedAt.isNull(), + ) + ..orderBy([ + (purchase) => OrderingTerm.desc(purchase.purchasedAt), + (purchase) => OrderingTerm.desc(purchase.id), + ]) + ..limit(count)) + .get(); - final updatedRows = await database.customUpdate( - 'UPDATE products ' - 'SET stock_quantity = stock_quantity - ? ' - 'WHERE id = ? AND stock_quantity >= ?', - variables: [ - Variable.withInt(amount), - Variable.withString(productId), - Variable.withInt(amount), - ], - updates: {database.products}, + await (database.update(database.tabItemPurchases) + ..where((purchase) => purchase.id.isIn(latest.map((row) => row.id)))) + .write(TabItemPurchasesCompanion(removedAt: Value(DateTime.now()))); + } + + /// Foreign keys aren't enforced, so purchases are deleted explicitly. + Future _deleteItemsOfTab(String tabId) async { + final itemIds = database.selectOnly(database.tabItems) + ..addColumns([database.tabItems.id]) + ..where(database.tabItems.tabId.equals(tabId)); + + await (database.delete( + database.tabItemPurchases, + )..where((purchase) => purchase.tabItemId.isInQuery(itemIds))).go(); + + await (database.delete( + database.tabItems, + )..where((item) => item.tabId.equals(tabId))).go(); + } + + Future _incrementTabVersion(String tabId) async { + await (database.update( + database.barTabs, + )..where((tab) => tab.id.equals(tabId))).write( + BarTabsCompanion.custom( + version: database.barTabs.version + const Constant(1), + ), ); - - if (updatedRows == 1) return; - - final product = await (database.select( - database.products, - )..where((row) => row.id.equals(productId))).getSingleOrNull(); - - if (product == null) { - throw Exception('Product not found'); - } - - throw const InsufficientStockException(); } - Future _increaseProductStock(String productId, int amount) async { - if (amount <= 0) { - throw ArgumentError.value(amount, 'amount', 'Must be greater than zero'); - } - - final updatedRows = await database.customUpdate( - 'UPDATE products ' - 'SET stock_quantity = stock_quantity + ? ' - 'WHERE id = ?', - variables: [Variable.withInt(amount), Variable.withString(productId)], - updates: {database.products}, - ); - - if (updatedRows == 1) return; - - throw Exception('Product not found'); - } - - @override - Future> getOpenTabs({bool includeRemoved = false}) async { - final query = database.select(database.barTabs) - ..where((tab) => tab.status.equals('open')) - ..orderBy([(tab) => OrderingTerm.desc(tab.openedAt)]); - - final tabRows = await query.get(); - - final tabs = []; - - for (final tabRow in tabRows) { - final items = includeRemoved - ? await _getItemsForHistory(tabRow.id) - : await _getItemsForTab(tabRow.id); - - tabs.add(_mapTabRow(tabRow, items)); - } - - return tabs; - } - - @override - Future getTabById(String id) async { - final query = database.select(database.barTabs) - ..where((tab) => tab.id.equals(id)); - - final tabRow = await query.getSingleOrNull(); - - if (tabRow == null) { - return null; - } - - final items = await _getItemsForTab(tabRow.id); - - return _mapTabRow(tabRow, items); - } - - @override - Future createTab({required String customerName}) async { - final id = _uuid.v4(); - final now = DateTime.now(); - - await database - .into(database.barTabs) - .insert( - BarTabsCompanion.insert( - id: id, - customerName: customerName, - status: const Value('open'), - openedAt: now, - version: const Value(1), - ), - ); - - final tab = await getTabById(id); - - if (tab == null) { - throw Exception('Could not create tab.'); - } - - return tab; - } - - @override - Future renameTab({ - required String tabId, - required String customerName, - }) async { + String _validateCustomerName(String customerName) { final name = customerName.trim(); if (name.isEmpty) { throw ArgumentError.value( @@ -329,56 +274,80 @@ class DriftBarTabService implements BarTabService { 'Must not be empty', ); } - - final updatedRows = - await (database.update(database.barTabs) - ..where((tab) => tab.id.equals(tabId))) - .write(BarTabsCompanion(customerName: Value(name))); - - if (updatedRows != 1) { - throw StateError('Tab not found.'); - } - - await _incrementTabVersion(tabId); + return name; } @override - Future> deleteTab(String tabId) async { - return database.transaction(() async { - final tab = await (database.select( - database.barTabs, - )..where((row) => row.id.equals(tabId))).getSingleOrNull(); + Stream> watchOpenTabs() { + return _tabsQuery().watch().map( + (rows) => _groupTabs(rows, includeRemoved: false), + ); + } - if (tab == null) return []; + @override + Future> getOpenTabs({bool includeRemoved = false}) async { + final rows = await _tabsQuery().get(); + return _groupTabs(rows, includeRemoved: includeRemoved); + } - final items = await _getItemsForTab(tabId); + @override + Future getTabById(String id) async { + final rows = await _tabsQuery(tabId: id).get(); + return _groupTabs(rows, includeRemoved: false).firstOrNull; + } - for (final item in items) { - await _increaseProductStock(item.productId, item.quantity); - } + @override + Future createTab({required String customerName}) async { + final id = _uuid.v4(); - await (database.delete( - database.tabItems, - )..where((item) => item.tabId.equals(tabId))).go(); + await database + .into(database.barTabs) + .insert( + BarTabsCompanion.insert( + id: id, + customerName: _validateCustomerName(customerName), + openedAt: DateTime.now(), + version: const Value(1), + ), + ); - final deletedRows = await (database.delete( - database.barTabs, - )..where((row) => row.id.equals(tabId))).go(); + return (await getTabById(id))!; + } - if (deletedRows != 1) { - throw StateError('Tab was changed before it could be deleted'); - } + @override + Future renameTab({ + required String tabId, + required String customerName, + }) async { + final name = _validateCustomerName(customerName); - return items; + await database.transaction(() async { + final updatedRows = + await (database.update(database.barTabs) + ..where((tab) => tab.id.equals(tabId))) + .write(BarTabsCompanion(customerName: Value(name))); + + if (updatedRows != 1) throw TabNotFoundException(tabId); + + await _incrementTabVersion(tabId); }); } - Future _incrementTabVersion(String tabId) async { - await database.customUpdate( - 'UPDATE bar_tabs SET version = version + 1 WHERE id = ?', - variables: [Variable.withString(tabId)], - updates: {database.barTabs}, - ); + @override + Future deleteTab(String tabId) async { + await database.transaction(() async { + final tab = await getTabById(tabId); + if (tab == null) return; + + for (final item in tab.items) { + await database.increaseStock(item.productId, item.quantity); + } + + await _deleteItemsOfTab(tabId); + await (database.delete( + database.barTabs, + )..where((row) => row.id.equals(tabId))).go(); + }); } @override @@ -387,48 +356,44 @@ class DriftBarTabService implements BarTabService { required Product product, }) async { await database.transaction(() async { - await _decreaseProductStock(product.id, 1); + await database.decreaseStock(product.id, 1); - final existingItemQuery = database.select(database.tabItems) - ..where( - (item) => - item.tabId.equals(tabId) & item.productId.equals(product.id), - ); - - final existingItem = await existingItemQuery.getSingleOrNull(); + final existingItem = + await (database.select(database.tabItems)..where( + (item) => + item.tabId.equals(tabId) & + item.productId.equals(product.id), + )) + .getSingleOrNull(); if (existingItem != null) { - final updateQuery = database.update(database.tabItems) - ..where((item) => item.id.equals(existingItem.id)); - - await updateQuery.write( + await (database.update( + database.tabItems, + )..where((item) => item.id.equals(existingItem.id))).write( TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), ); - await _recordPurchase(tabItemId: existingItem.id); + await _recordPurchase(existingItem.id); + } else { + final tabItemId = _uuid.v4(); - return; + await database + .into(database.tabItems) + .insert( + TabItemsCompanion.insert( + id: tabItemId, + tabId: tabId, + productId: product.id, + productName: product.name, + quantity: 1, + unitPriceInCents: product.priceInCents, + createdAt: DateTime.now(), + ), + ); + await _recordPurchase(tabItemId); } - final tabItemId = _uuid.v4(); - - await database - .into(database.tabItems) - .insert( - TabItemsCompanion.insert( - id: tabItemId, - tabId: tabId, - productId: product.id, - productName: product.name, - quantity: 1, - unitPriceInCents: product.priceInCents, - createdAt: DateTime.now(), - ), - ); - - await _recordPurchase(tabItemId: tabItemId); + await _incrementTabVersion(tabId); }); - - await _incrementTabVersion(tabId); } @override @@ -436,100 +401,61 @@ class DriftBarTabService implements BarTabService { required String tabItemId, required int delta, }) async { - String? tabId; + if (delta == 0) return 0; - final actualDelta = await database.transaction(() async { + return database.transaction(() async { final item = await (database.select( database.tabItems, )..where((row) => row.id.equals(tabItemId))).getSingleOrNull(); - if (item == null || delta == 0 || (item.quantity == 0 && delta < 0)) { - return 0; - } + if (item == null) return 0; - tabId = item.tabId; + final newQuantity = max(item.quantity + delta, 0); + final actualDelta = newQuantity - item.quantity; - final requestedQuantity = item.quantity + delta; - final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta; + if (actualDelta == 0) return 0; if (actualDelta > 0) { - await _decreaseProductStock(item.productId, actualDelta); - + await database.decreaseStock(item.productId, actualDelta); for (var index = 0; index < actualDelta; index++) { - await _recordPurchase(tabItemId: item.id); + await _recordPurchase(item.id); } } else { - await _increaseProductStock(item.productId, -actualDelta); - - final purchaseRows = await _ensurePurchaseRows(item); - final activePurchaseRows = - purchaseRows - .where((purchase) => purchase.removedAt == null) - .toList() - ..sort((a, b) => b.purchasedAt.compareTo(a.purchasedAt)); - final removedAt = DateTime.now(); - - for (final purchase in activePurchaseRows.take(-actualDelta)) { - await (database.update(database.tabItemPurchases) - ..where((row) => row.id.equals(purchase.id))) - .write(TabItemPurchasesCompanion(removedAt: Value(removedAt))); - } + await database.increaseStock(item.productId, -actualDelta); + await _removeLatestPurchases(item.id, -actualDelta); } - if (requestedQuantity <= 0) { - final updatedRows = - await (database.update(database.tabItems) - ..where((row) => row.id.equals(tabItemId))) - .write(const TabItemsCompanion(quantity: Value(0))); + await (database.update(database.tabItems) + ..where((row) => row.id.equals(tabItemId))) + .write(TabItemsCompanion(quantity: Value(newQuantity))); - if (updatedRows != 1) { - throw StateError( - 'Tab item was changed before its quantity was updated', - ); - } - } else { - final updatedRows = - await (database.update(database.tabItems) - ..where((row) => row.id.equals(tabItemId))) - .write(TabItemsCompanion(quantity: Value(requestedQuantity))); - - if (updatedRows != 1) { - throw StateError( - 'Tab item was changed before its quantity was updated', - ); - } - } + await _incrementTabVersion(item.tabId); return actualDelta; }); - - if (tabId != null && actualDelta != 0) { - await _incrementTabVersion(tabId!); - } - - return actualDelta; } @override Future closeTab( String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, + int? expectedVersion, }) async { await database.transaction(() async { - final tabQuery = database.select(database.barTabs) - ..where((tab) => tab.id.equals(tabId)); + final tabRow = await _getTabRow(tabId); + if (tabRow == null) throw TabNotFoundException(tabId); - final tabRow = await tabQuery.getSingleOrNull(); - - if (tabRow == null) { - return; + if (expectedVersion != null && tabRow.version != expectedVersion) { + throw TabVersionConflictException( + expectedVersion: expectedVersion, + actualVersion: tabRow.version, + ); } - final items = await _getItemsForHistory(tabId); + final rows = await _tabsQuery(tabId: tabId).get(); + final items = _groupTabs(rows, includeRemoved: true).first.items; - if (items.isEmpty) { - return; - } + if (items.isEmpty) return; final closedTabId = _uuid.v4(); @@ -541,44 +467,83 @@ class DriftBarTabService implements BarTabService { originalTabId: tabId, customerName: tabRow.customerName, closedAt: DateTime.now(), - paymentMethod: Value(paymentMethod.value), + paymentMethod: Value(paymentMethod), ), ); - for (final item in items) { - for (final purchase in item.purchases) { - await database - .into(database.closedTabItems) - .insert( - ClosedTabItemsCompanion.insert( - id: _uuid.v4(), - closedTabId: closedTabId, - productId: item.productId, - productName: item.productName, - quantity: 1, - unitPriceInCents: item.unitPriceInCents, - purchasedAt: Value(purchase.purchasedAt), - removedAt: Value(purchase.removedAt), - ), - ); - } - } + await database.batch((batch) { + batch.insertAll(database.closedTabItems, [ + for (final item in items) + for (final purchase in item.purchases) + ClosedTabItemsCompanion.insert( + id: _uuid.v4(), + closedTabId: closedTabId, + productId: item.productId, + productName: item.productName, + quantity: 1, + unitPriceInCents: item.unitPriceInCents, + purchasedAt: Value(purchase.purchasedAt), + removedAt: Value(purchase.removedAt), + ), + ]); + }); - final deleteQuery = database.delete(database.tabItems) - ..where((item) => item.tabId.equals(tabId)); - - await deleteQuery.go(); + await _deleteItemsOfTab(tabId); + await _incrementTabVersion(tabId); }); + } - await _incrementTabVersion(tabId); + /// Loads the items for every row in one query instead of one per tab. + Future> _withItems(List tabRows) async { + if (tabRows.isEmpty) return []; + + final itemRows = + await (database.select(database.closedTabItems) + ..where( + (item) => item.closedTabId.isIn(tabRows.map((row) => row.id)), + ) + ..orderBy([ + (item) => OrderingTerm.desc(item.purchasedAt), + (item) => OrderingTerm.desc(item.id), + ])) + .get(); + + final itemsByTab = >{}; + for (final row in itemRows) { + itemsByTab + .putIfAbsent(row.closedTabId, () => []) + .add(_mapClosedItemRow(row)); + } + + return [ + for (final row in tabRows) + ClosedTab( + id: row.id, + originalTabId: row.originalTabId, + customerName: row.customerName, + closedAt: row.closedAt, + paymentMethod: row.paymentMethod, + items: itemsByTab[row.id] ?? [], + ), + ]; + } + + SimpleSelectStatement<$ClosedTabsTable, ClosedTabsRow> _closedTabsQuery({ + String? customerName, + }) { + final query = database.select(database.closedTabs) + ..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]); + + if (customerName != null && customerName.isNotEmpty) { + query.where((tab) => tab.customerName.equals(customerName)); + } + + return query; } @override Future> getClosedTabs() async { - final count = await getClosedTabCount(); - if (count == 0) return []; - - return getClosedTabsPaginated(limit: count, offset: 0); + return _withItems(await _closedTabsQuery().get()); } @override @@ -587,53 +552,21 @@ class DriftBarTabService implements BarTabService { int offset = 0, String? customerName, }) async { - var query = database.select(database.closedTabs) - ..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]) + final query = _closedTabsQuery(customerName: customerName) ..limit(limit, offset: offset); - if (customerName != null && customerName.isNotEmpty) { - query = query..where((tab) => tab.customerName.equals(customerName)); - } - - final closedTabRows = await query.get(); - - final closedTabs = []; - - for (final row in closedTabRows) { - final itemsQuery = database.select(database.closedTabItems) - ..where((item) => item.closedTabId.equals(row.id)) - ..orderBy([ - (item) => OrderingTerm.desc(item.purchasedAt), - (item) => OrderingTerm.desc(item.id), - ]); - - final itemRows = await itemsQuery.get(); - - closedTabs.add( - ClosedTab( - id: row.id, - originalTabId: row.originalTabId, - customerName: row.customerName, - closedAt: row.closedAt, - paymentMethod: PaymentMethod.fromValue(row.paymentMethod), - items: itemRows.map(_mapClosedItemRow).toList(), - ), - ); - } - - return closedTabs; + return _withItems(await query.get()); } @override Future getClosedTabCount({String? customerName}) async { final countExpr = database.closedTabs.id.count(); - var query = database.selectOnly(database.closedTabs) + final query = database.selectOnly(database.closedTabs) ..addColumns([countExpr]); if (customerName != null && customerName.isNotEmpty) { - query = query - ..where(database.closedTabs.customerName.equals(customerName)); + query.where(database.closedTabs.customerName.equals(customerName)); } final row = await query.getSingle(); @@ -642,7 +575,13 @@ class DriftBarTabService implements BarTabService { @override Future> getDistinctCustomerNames() async { - final rows = await database.select(database.closedTabs).get(); - return rows.map((row) => row.customerName).toSet().toList()..sort(); + final customerName = database.closedTabs.customerName; + + final query = database.selectOnly(database.closedTabs, distinct: true) + ..addColumns([customerName]) + ..orderBy([OrderingTerm.asc(customerName)]); + + final rows = await query.get(); + return rows.map((row) => row.read(customerName)!).toList(); } } diff --git a/lib/services/default_product_seeder.dart b/lib/services/default_product_seeder.dart index c00eebb..2fb4a5e 100644 --- a/lib/services/default_product_seeder.dart +++ b/lib/services/default_product_seeder.dart @@ -1,12 +1,14 @@ - import 'dart:io'; +import 'dart:async'; +import 'dart:io'; + +import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:drift/drift.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:uuid/uuid.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; @@ -52,7 +54,9 @@ class DefaultProductSeeder { imagePath = await _copyBundledImage(name, imagesDir); } - await database.into(database.products).insert( + await database + .into(database.products) + .insert( ProductsCompanion.insert( id: uuid.v4(), name: name, @@ -67,7 +71,7 @@ class DefaultProductSeeder { }); } catch (e, stack) { debugPrint('DefaultProductSeeder: seedIfEmpty error: $e'); - Sentry.captureException(e, stackTrace: stack); + unawaited(Sentry.captureException(e, stackTrace: stack)); } } @@ -95,7 +99,10 @@ class DefaultProductSeeder { } } - Future _copyBundledImage(String productName, Directory imagesDir) async { + Future _copyBundledImage( + String productName, + Directory imagesDir, + ) async { final slug = _toSlug(productName); for (final ext in ['png', 'jpg', 'webp']) { @@ -107,10 +114,9 @@ class DefaultProductSeeder { final destPath = path.join(imagesDir.path, fileName); final destFile = File(destPath); - await destFile.writeAsBytes(bytes.buffer.asUint8List( - bytes.offsetInBytes, - bytes.lengthInBytes, - )); + await destFile.writeAsBytes( + bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes), + ); return destPath; } catch (_) { @@ -118,8 +124,10 @@ class DefaultProductSeeder { } } - debugPrint('DefaultProductSeeder: no bundled image found for "$productName" ' - '(looked for assets/products/$slug.{png,jpg,webp})'); + debugPrint( + 'DefaultProductSeeder: no bundled image found for "$productName" ' + '(looked for assets/products/$slug.{png,jpg,webp})', + ); return null; } diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart index e7b77e9..01a8bec 100644 --- a/lib/services/export_service.dart +++ b/lib/services/export_service.dart @@ -1,157 +1,89 @@ +import 'dart:async'; import 'dart:convert'; -import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; -import '../database/app_database.dart'; +import '../models/closed_tab.dart'; +import '../models/closed_tab_item.dart'; import '../utils/app_config.dart'; +import 'bar_tab_service.dart'; abstract class ExportService { Future uploadToServer(); } +/// Uploads the closed-tab history to the backup server. Best effort: a +/// failed backup is logged but never interrupts the bar. class DefaultExportService implements ExportService { - final AppDatabase database; + final BarTabService barTabService; + final http.Client _client; - DefaultExportService({required this.database}); + DefaultExportService({required this.barTabService, http.Client? client}) + : _client = client ?? http.Client(); @override Future uploadToServer() async { try { - final closedTabs = await _getAllClosedTabs(); - final exportData = _buildExportPayload(closedTabs); + final closedTabs = await barTabService.getClosedTabs(); + final appVersion = (await PackageInfo.fromPlatform()).version; - final response = await http.post( + final response = await _client.post( Uri.parse(kExportServerUrl), headers: {'Content-Type': 'application/json'}, - body: jsonEncode(exportData), + body: jsonEncode(_buildPayload(closedTabs, appVersion: appVersion)), ); if (response.statusCode != 200 && response.statusCode != 201) { - // Should prob log this one tho + unawaited( + Sentry.captureMessage( + 'Backup upload failed with HTTP ${response.statusCode}', + level: SentryLevel.warning, + ), + ); } - } catch (_) { - // if this fails who cares + } on Exception catch (e) { + // Mostly offline tablets; not worth an error report. + debugPrint('ExportService: backup upload failed: $e'); } } - Future> _getAllClosedTabs() async { - final tabQuery = database.select(database.closedTabs) - ..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]); - - final tabRows = await tabQuery.get(); - - final result = <_ClosedTabExport>[]; - - for (final tabRow in tabRows) { - final itemsQuery = database.select(database.closedTabItems) - ..where((item) => item.closedTabId.equals(tabRow.id)); - - final itemRows = await itemsQuery.get(); - - result.add( - _ClosedTabExport( - id: tabRow.id, - originalTabId: tabRow.originalTabId, - customerName: tabRow.customerName, - closedAt: tabRow.closedAt.toIso8601String(), - paymentMethod: tabRow.paymentMethod, - items: itemRows - .map( - (row) => _ClosedTabItemExport( - productId: row.productId, - productName: row.productName, - quantity: row.quantity, - unitPriceInCents: row.unitPriceInCents, - purchasedAt: row.purchasedAt?.toIso8601String(), - removedAt: row.removedAt?.toIso8601String(), - ), - ) - .toList(), - ), - ); - } - - return result; - } - - Map _buildExportPayload(List<_ClosedTabExport> tabs) { + Map _buildPayload( + List tabs, { + required String appVersion, + }) { return { 'exportedAt': DateTime.now().toIso8601String(), - 'appVersion': '1.0.7', - 'closedTabs': tabs - .map( - (tab) => { - 'id': tab.id, - 'originalTabId': tab.originalTabId, - 'customerName': tab.customerName, - 'closedAt': tab.closedAt, - 'paymentMethod': tab.paymentMethod, - 'items': tab.items - .map( - (item) => { - 'productId': item.productId, - 'productName': item.productName, - 'quantity': item.quantity, - 'unitPriceInCents': item.unitPriceInCents, - 'purchasedAt': item.purchasedAt, - 'removedAt': item.removedAt, - 'isRemoved': item.isRemoved, - 'lineTotalInCents': item.lineTotalInCents, - }, - ) - .toList(), - 'itemCount': tab.items.fold( - 0, - (sum, item) => sum + (item.isRemoved ? 0 : item.quantity), - ), - 'totalInCents': tab.items.fold( - 0, - (sum, item) => sum + item.lineTotalInCents, - ), - }, - ) - .toList(), + 'appVersion': appVersion, + 'closedTabs': tabs.map(_tabToJson).toList(), + }; + } + + Map _tabToJson(ClosedTab tab) { + return { + 'id': tab.id, + 'originalTabId': tab.originalTabId, + 'customerName': tab.customerName, + 'closedAt': tab.closedAt.toIso8601String(), + 'paymentMethod': tab.paymentMethod.value, + 'items': tab.items.map(_itemToJson).toList(), + 'itemCount': tab.itemCount, + 'totalInCents': tab.totalInCents, + }; + } + + Map _itemToJson(ClosedTabItem item) { + return { + 'productId': item.productId, + 'productName': item.productName, + 'quantity': item.quantity, + 'unitPriceInCents': item.unitPriceInCents, + 'purchasedAt': item.purchasedAt?.toIso8601String(), + 'removedAt': item.removedAt?.toIso8601String(), + 'isRemoved': item.isRemoved, + 'lineTotalInCents': item.lineTotalInCents, }; } } - -class _ClosedTabItemExport { - final String productId; - final String productName; - final int quantity; - final int unitPriceInCents; - final String? purchasedAt; - final String? removedAt; - - _ClosedTabItemExport({ - required this.productId, - required this.productName, - required this.quantity, - required this.unitPriceInCents, - required this.purchasedAt, - required this.removedAt, - }); - - bool get isRemoved => removedAt != null; - - int get lineTotalInCents => isRemoved ? 0 : quantity * unitPriceInCents; -} - -class _ClosedTabExport { - final String id; - final String originalTabId; - final String customerName; - final String closedAt; - final String paymentMethod; - final List<_ClosedTabItemExport> items; - - _ClosedTabExport({ - required this.id, - required this.originalTabId, - required this.customerName, - required this.closedAt, - required this.paymentMethod, - required this.items, - }); -} diff --git a/lib/services/kooltap_api_client.dart b/lib/services/kooltap_api_client.dart new file mode 100644 index 0000000..0736d18 --- /dev/null +++ b/lib/services/kooltap_api_client.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/remote_command.dart'; +import '../models/venue.dart'; +import '../utils/app_config.dart'; + +class ApiException implements Exception { + final int statusCode; + + /// Machine-readable reason from the server, e.g. `invalid_registration_code`. + final String? code; + final String message; + + const ApiException(this.statusCode, this.message, {this.code}); + + @override + String toString() => + 'HTTP $statusCode${code == null ? '' : ' ($code)'}: $message'; +} + +/// The device token was revoked or is invalid. +class UnauthorizedException extends ApiException { + const UnauthorizedException({super.code, String? message}) + : super(401, message ?? 'Unauthorized'); +} + +class RateLimitedException extends ApiException { + final Duration retryAfter; + + const RateLimitedException(this.retryAfter) : super(429, 'Rate limited'); +} + +class DeviceCredentials { + final String deviceId; + final String token; + + const DeviceCredentials({required this.deviceId, required this.token}); +} + +class KoolTapApiClient { + final http.Client _client; + final String baseUrl; + + KoolTapApiClient({http.Client? client, this.baseUrl = kRemoteServerUrl}) + : _client = client ?? http.Client(); + + static const _timeout = Duration(seconds: 15); + static const _defaultRetryAfter = Duration(seconds: 60); + + Future> getVenues() async { + final response = await _client + .get(Uri.parse('$baseUrl/venues/')) + .timeout(_timeout); + + final data = _decode(response) as List; + return [ + for (final venue in data) Venue.fromJson(venue as Map), + ]; + } + + Future registerDevice({ + required String venueId, + required String deviceName, + required String registrationCode, + }) async { + final data = + await _post('/devices/register', { + 'venueId': venueId, + 'name': deviceName, + 'registrationCode': registrationCode, + }) + as Map; + + return DeviceCredentials( + deviceId: data['deviceId'] as String, + token: data['token'] as String, + ); + } + + Future sync({ + required String token, + required SyncRequest request, + }) async { + final data = await _post( + '/devices/${request.deviceId}/sync', + request.toJson(), + token: token, + ); + + return SyncResponse.fromJson(data as Map? ?? {}); + } + + Future ackCommand({ + required String token, + required String deviceId, + required CommandAck ack, + }) async { + await _post( + '/devices/$deviceId/commands/${ack.commandId}/ack', + ack.toJson(), + token: token, + ); + } + + Future _post( + String path, + Map body, { + String? token, + }) async { + final response = await _client + .post( + Uri.parse('$baseUrl$path'), + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode(body), + ) + .timeout(_timeout); + + return _decode(response, authenticated: token != null); + } + + /// A 401 only means the device token is no longer valid when the request + /// used one; while pairing it means the registration code was rejected. + Object? _decode(http.Response response, {bool authenticated = false}) { + final status = response.statusCode; + + if (status == 401 && authenticated) { + final error = _error(response); + throw UnauthorizedException(code: error.code, message: error.message); + } + + if (status == 429) { + final seconds = int.tryParse(response.headers['retry-after'] ?? ''); + throw RateLimitedException( + seconds == null ? _defaultRetryAfter : Duration(seconds: seconds), + ); + } + + if (status < 200 || status >= 300) { + final error = _error(response); + throw ApiException(status, error.message ?? '', code: error.code); + } + + if (response.body.isEmpty) return null; + + return jsonDecode(response.body); + } + + /// The server reports errors as `{"error": {"code": ..., "message": ...}}`; + /// anything else (e.g. a proxy's HTML error page) falls back to the reason + /// phrase. + ({String? code, String? message}) _error(http.Response response) { + final fallback = response.reasonPhrase; + try { + final error = + (jsonDecode(response.body) as Map)['error']; + if (error is! Map) { + return (code: null, message: fallback); + } + + return ( + code: error['code'] as String?, + message: error['message'] as String? ?? fallback, + ); + } on FormatException { + return (code: null, message: fallback); + } on TypeError { + return (code: null, message: fallback); + } + } +} diff --git a/lib/services/pin_lock_service.dart b/lib/services/pin_lock_service.dart index 74b7a7d..6678d1a 100644 --- a/lib/services/pin_lock_service.dart +++ b/lib/services/pin_lock_service.dart @@ -1,24 +1,45 @@ import 'dart:convert'; +import 'dart:isolate'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +class PinLockedOutException implements Exception { + final Duration remaining; + + const PinLockedOutException(this.remaining); + + @override + String toString() => 'PIN locked for ${remaining.inSeconds}s'; +} + class PinLockService { PinLockService({ FlutterSecureStorage? storage, - String saltKey = _defaultSaltKey, - String hashKey = _defaultHashKey, + this._saltKey = _defaultSaltKey, + this._hashKey = _defaultHashKey, + DateTime Function()? clock, }) : _storage = storage ?? const FlutterSecureStorage(), - _saltKey = saltKey, - _hashKey = hashKey; + _clock = clock ?? DateTime.now; static const _defaultSaltKey = 'pin_salt'; static const _defaultHashKey = 'pin_hash'; + static const _hashPrefix = 'pbkdf2-sha256'; + static const _iterations = 50000; + + static const _freeAttempts = 5; + static const _baseLockout = Duration(seconds: 30); + static const _maxLockout = Duration(minutes: 15); + final FlutterSecureStorage _storage; final String _saltKey; final String _hashKey; + final DateTime Function() _clock; + + String get _failuresKey => '${_hashKey}_failures'; + String get _lockedUntilKey => '${_hashKey}_locked_until'; Future hasPin() async { final hash = await _storage.read(key: _hashKey); @@ -27,24 +48,82 @@ class PinLockService { Future setPin(String pin) async { final salt = _generateSalt(); - final hash = _hash(pin, salt); + final hash = await _derive(pin, salt, _iterations); await _storage.write(key: _saltKey, value: salt); await _storage.write(key: _hashKey, value: hash); + await _clearFailures(); } + /// Throws [PinLockedOutException] after too many wrong attempts. The + /// lockout is persisted, so restarting the app doesn't reset it. Future verifyPin(String pin) async { + await _throwIfLockedOut(); + final salt = await _storage.read(key: _saltKey); final storedHash = await _storage.read(key: _hashKey); if (salt == null || storedHash == null) return false; - return _hash(pin, salt) == storedHash; + final isLegacy = !storedHash.startsWith('$_hashPrefix\$'); + final candidate = isLegacy + ? _legacyHash(pin, salt) + : await _derive(pin, salt, _iterationsOf(storedHash)); + + if (!_constantTimeEquals(candidate, storedHash)) { + await _registerFailure(); + return false; + } + + if (isLegacy) { + await setPin(pin); + } else { + await _clearFailures(); + } + + return true; } Future clearPin() async { await _storage.delete(key: _saltKey); await _storage.delete(key: _hashKey); + await _clearFailures(); + } + + Future _throwIfLockedOut() async { + final lockedUntil = int.tryParse( + await _storage.read(key: _lockedUntilKey) ?? '', + ); + if (lockedUntil == null) return; + + final remaining = DateTime.fromMillisecondsSinceEpoch( + lockedUntil, + ).difference(_clock()); + + if (remaining > Duration.zero) throw PinLockedOutException(remaining); + } + + /// Every failure past the free attempts doubles the lockout. + Future _registerFailure() async { + final failures = + (int.tryParse(await _storage.read(key: _failuresKey) ?? '') ?? 0) + 1; + await _storage.write(key: _failuresKey, value: '$failures'); + + if (failures < _freeAttempts) return; + + final exponent = min(failures - _freeAttempts, 10); + final lockout = _baseLockout * (1 << exponent); + final capped = lockout > _maxLockout ? _maxLockout : lockout; + + await _storage.write( + key: _lockedUntilKey, + value: '${_clock().add(capped).millisecondsSinceEpoch}', + ); + } + + Future _clearFailures() async { + await _storage.delete(key: _failuresKey); + await _storage.delete(key: _lockedUntilKey); } String _generateSalt([int length = 16]) { @@ -53,8 +132,52 @@ class PinLockService { return base64UrlEncode(bytes); } - String _hash(String pin, String salt) { - final bytes = utf8.encode('$salt:$pin'); - return sha256.convert(bytes).toString(); + int _iterationsOf(String hash) { + return int.parse(hash.split('\$')[1]); + } + + /// Hashes are stored as `pbkdf2-sha256$$`, so the + /// iteration count can be raised later without breaking existing PINs. + /// + /// Static so the isolate closure can't capture `this`. + static Future _derive(String pin, String salt, int iterations) async { + final key = await Isolate.run( + () => _pbkdf2(utf8.encode(pin), utf8.encode(salt), iterations), + ); + final hex = key.map((byte) => byte.toRadixString(16).padLeft(2, '0')); + return '$_hashPrefix\$$iterations\$${hex.join()}'; + } + + /// Single-block PBKDF2-HMAC-SHA256 (a 32-byte key). + static List _pbkdf2(List password, List salt, int iterations) { + final hmac = Hmac(sha256, password); + + var block = hmac.convert([...salt, 0, 0, 0, 1]).bytes; + final key = List.of(block); + + for (var round = 1; round < iterations; round++) { + block = hmac.convert(block).bytes; + for (var index = 0; index < key.length; index++) { + key[index] ^= block[index]; + } + } + + return key; + } + + /// The original single-SHA-256 format, only used to verify and upgrade + /// PINs set by older versions. + String _legacyHash(String pin, String salt) { + return sha256.convert(utf8.encode('$salt:$pin')).toString(); + } + + bool _constantTimeEquals(String a, String b) { + if (a.length != b.length) return false; + + var difference = 0; + for (var index = 0; index < a.length; index++) { + difference |= a.codeUnitAt(index) ^ b.codeUnitAt(index); + } + return difference == 0; } } diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart index f4b56ce..124d9fb 100644 --- a/lib/services/product_service.dart +++ b/lib/services/product_service.dart @@ -2,16 +2,16 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; +import '../database/stock_queries.dart'; import '../models/product.dart'; -class InsufficientStockException implements Exception { - const InsufficientStockException(); - - @override - String toString() => 'Not enough stock'; -} +export '../database/stock_queries.dart' + show InsufficientStockException, ProductNotFoundException; abstract class ProductService { + /// Active products, re-emitted whenever the products table changes. + Stream> watchProducts(); + Future> getProducts(); Future getProductById(String id); @@ -53,15 +53,20 @@ class DriftProductService implements ProductService { ); } - @override - Future> getProducts() async { - final query = database.select(database.products) + SimpleSelectStatement<$ProductsTable, ProductRow> _activeProductsQuery() { + return database.select(database.products) ..where((product) => product.active.equals(true)) ..orderBy([(product) => OrderingTerm.asc(product.name)]); + } - final rows = await query.get(); + @override + Stream> watchProducts() { + return _activeProductsQuery().map(_mapRowToProduct).watch(); + } - return rows.map(_mapRowToProduct).toList(); + @override + Future> getProducts() { + return _activeProductsQuery().map(_mapRowToProduct).get(); } @override @@ -129,49 +134,12 @@ class DriftProductService implements ProductService { } @override - Future decreaseStock(String productId, int amount) async { - _validateStockAmount(amount); - - final updatedRows = await database.customUpdate( - 'UPDATE products ' - 'SET stock_quantity = stock_quantity - ? ' - 'WHERE id = ? AND stock_quantity >= ?', - variables: [ - Variable.withInt(amount), - Variable.withString(productId), - Variable.withInt(amount), - ], - updates: {database.products}, - ); - - if (updatedRows == 1) return; - - final product = await getProductById(productId); - if (product == null) throw Exception('Product not found'); - - throw const InsufficientStockException(); + Future decreaseStock(String productId, int amount) { + return database.decreaseStock(productId, amount); } @override - Future increaseStock(String productId, int amount) async { - _validateStockAmount(amount); - - final updatedRows = await database.customUpdate( - 'UPDATE products ' - 'SET stock_quantity = stock_quantity + ? ' - 'WHERE id = ?', - variables: [Variable.withInt(amount), Variable.withString(productId)], - updates: {database.products}, - ); - - if (updatedRows == 1) return; - - throw Exception('Product not found'); - } - - void _validateStockAmount(int amount) { - if (amount <= 0) { - throw ArgumentError.value(amount, 'amount', 'Must be greater than zero'); - } + Future increaseStock(String productId, int amount) { + return database.increaseStock(productId, amount); } } diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index fc4ec3d..dbf0717 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -1,33 +1,52 @@ import 'dart:async'; -import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:http/http.dart' as http; import 'package:sentry_flutter/sentry_flutter.dart'; import '../database/app_database.dart'; +import '../models/bar_tab.dart'; import '../models/payment_method.dart'; import '../models/remote_command.dart'; import '../utils/app_config.dart'; import 'bar_tab_service.dart'; +import 'kooltap_api_client.dart'; + +typedef _Credentials = ({String deviceId, String token}); + +typedef _CommandOutcome = ({CommandResult result, String? message}); class RemoteSyncService { final AppDatabase database; final BarTabService barTabService; + final KoolTapApiClient apiClient; final FlutterSecureStorage secureStorage; + /// How long tab changes must be quiet before they are synced. + final Duration changeDebounce; + + /// The longest a tab change waits for a sync while changes keep coming. + final Duration changeMaxDelay; + RemoteSyncService({ required this.database, required this.barTabService, + required this.apiClient, FlutterSecureStorage? secureStorage, + this.changeDebounce = const Duration(milliseconds: kSyncChangeDebounceMs), + this.changeMaxDelay = const Duration(milliseconds: kSyncChangeMaxDelayMs), }) : secureStorage = secureStorage ?? const FlutterSecureStorage(); static const _deviceIdKey = 'remote_device_id'; static const _authTokenKey = 'remote_auth_token'; - static const _serverUrlKey = 'remote_server_url'; static const _venueIdKey = 'remote_venue_id'; + static const _syncStateId = 'default'; + + static const _pollingInterval = Duration( + seconds: kSyncPollingIntervalSeconds, + ); + static const _maxBackoff = Duration(seconds: kSyncMaxBackoffSeconds); final _eventController = StreamController.broadcast(); @@ -36,38 +55,52 @@ class RemoteSyncService { bool _isSyncing = false; bool get isSyncing => _isSyncing; - Timer? _syncTimer; - int _consecutiveFailures = 0; + Timer? _timer; + bool _isRunning = false; bool _paused = false; + int _consecutiveFailures = 0; - Future get deviceId async => - await secureStorage.read(key: _deviceIdKey); + StreamSubscription>? _tabChanges; + Timer? _changeTimer; + DateTime? _firstPendingChange; + bool _syncQueued = false; - Future get authToken async => - await secureStorage.read(key: _authTokenKey); - - Future get venueId async => - await secureStorage.read(key: _venueIdKey); + Future get venueId => secureStorage.read(key: _venueIdKey); Future get isConfigured async { - final token = await authToken; - final vid = await venueId; - return token != null && token.isNotEmpty && vid != null && vid.isNotEmpty; + final token = await secureStorage.read(key: _authTokenKey); + final venueId = await this.venueId; + return (token?.isNotEmpty ?? false) && (venueId?.isNotEmpty ?? false); } - String get _baseUrl => kRemoteServerUrl; + Future<_Credentials?> _credentials() async { + final deviceId = await secureStorage.read(key: _deviceIdKey); + final token = await secureStorage.read(key: _authTokenKey); + if (deviceId == null || token == null) return null; - void startPeriodicSync({int intervalSeconds = 90}) { - _syncTimer?.cancel(); - _syncTimer = Timer.periodic( - Duration(seconds: intervalSeconds), - (_) => _onTick(), - ); + return (deviceId: deviceId, token: token); + } + + void startPeriodicSync() { + _isRunning = true; + // The first emission is the current state, not a change + _tabChanges ??= barTabService + .watchOpenTabs() + .skip(1) + .listen((_) => _onTabsChanged()); + _scheduleNext(_pollingInterval); } void stopPeriodicSync() { - _syncTimer?.cancel(); - _syncTimer = null; + _isRunning = false; + _timer?.cancel(); + _timer = null; + _changeTimer?.cancel(); + _changeTimer = null; + _firstPendingChange = null; + _syncQueued = false; + unawaited(_tabChanges?.cancel()); + _tabChanges = null; } void pause() { @@ -76,321 +109,285 @@ class RemoteSyncService { void resume() { _paused = false; + // Catch up right away instead of waiting for the next poll + _onTabsChanged(); } - Future _onTick() async { - if (_isSyncing || _paused) return; - await sync(); + /// Pushes tab changes to the server soon after they happen instead of + /// waiting for the next poll. A burst of changes (several drinks rung up in + /// a row) becomes one sync, sent once things are quiet for [changeDebounce] + /// but never later than [changeMaxDelay] after the first change. + void _onTabsChanged() { + // While failing, the backoff timer decides when to retry + if (!_isRunning || _paused || _consecutiveFailures > 0) return; + + final now = DateTime.now(); + _firstPendingChange ??= now; + final untilMaxDelay = changeMaxDelay - now.difference(_firstPendingChange!); + final delay = untilMaxDelay < changeDebounce + ? (untilMaxDelay.isNegative ? Duration.zero : untilMaxDelay) + : changeDebounce; + + _changeTimer?.cancel(); + _changeTimer = Timer(delay, () { + _firstPendingChange = null; + unawaited(sync()); + }); + } + + /// Every sync schedules the next one, so a failure can stretch the delay + /// without stopping the loop. + void _scheduleNext(Duration delay) { + _timer?.cancel(); + if (!_isRunning) return; + + _timer = Timer(delay, () { + if (_paused) { + _scheduleNext(_pollingInterval); + return; + } + unawaited(sync()); + }); + } + + Duration _backoff() { + final exponent = (_consecutiveFailures - 1).clamp(0, 3); + final delay = _pollingInterval * (1 << exponent); + return delay > _maxBackoff ? _maxBackoff : delay; } Future sync() async { - if (_isSyncing) return; + if (_isSyncing) { + // Changes made during a sync must not wait for the next poll + _syncQueued = true; + return; + } - final configured = await isConfigured; - if (!configured) return; + final credentials = await _credentials(); + if (credentials == null || !await isConfigured) return; _isSyncing = true; + Duration nextDelay; try { - await _doSync(); + await _doSync(credentials); _consecutiveFailures = 0; - _eventController.add(const RemoteSyncEvent( - type: RemoteSyncEventType.syncCompleted, - )); + nextDelay = _pollingInterval; + _emit(RemoteSyncEventType.syncCompleted); + } on UnauthorizedException { + debugPrint('RemoteSyncService: token revoked'); + await secureStorage.delete(key: _authTokenKey); + stopPeriodicSync(); + _emit(RemoteSyncEventType.unauthorized); + return; + } on RateLimitedException catch (e) { + nextDelay = await _recordFailure(e, retryAfter: e.retryAfter); } catch (e, stack) { - _consecutiveFailures++; - debugPrint('RemoteSyncService: sync failed: $e'); - Sentry.captureException(e, stackTrace: stack); - _eventController.add(RemoteSyncEvent( - type: RemoteSyncEventType.syncFailed, - message: e.toString(), - )); - _applyBackoff(); + unawaited(Sentry.captureException(e, stackTrace: stack)); + nextDelay = await _recordFailure(e); } finally { _isSyncing = false; } + + _scheduleNext(nextDelay); + + if (_syncQueued) { + _syncQueued = false; + if (_consecutiveFailures == 0) unawaited(sync()); + } } - Future _doSync() async { - final token = await authToken; - if (token == null) return; + Future _recordFailure(Object error, {Duration? retryAfter}) async { + debugPrint('RemoteSyncService: sync failed: $error'); + _consecutiveFailures++; + await _updateSyncState(success: false); + _emit(RemoteSyncEventType.syncFailed, message: error.toString()); - final deviceId = await this.deviceId; - if (deviceId == null) return; + return retryAfter ?? _backoff(); + } + Future _doSync(_Credentials credentials) async { final state = await _getSyncState(); - final openTabs = await _buildSnapshot(); + final openTabs = await barTabService.getOpenTabs(); - final request = SyncRequest( - deviceId: deviceId, - cursor: state?.serverCursor, - openTabs: openTabs, + final response = await apiClient.sync( + token: credentials.token, + request: SyncRequest( + deviceId: credentials.deviceId, + cursor: state?.serverCursor, + openTabs: openTabs.map(_toSnapshot).toList(), + ), ); - final response = await _httpPost( - '$_baseUrl/devices/$deviceId/sync', - body: request.toJson(), - token: token, - ); - - if (response == null) return; - - final syncResponse = SyncResponse.fromJson(response); - - for (final command in syncResponse.commands) { - await _processCommand(command); + for (final command in response.commands) { + await _processCommand(command, credentials); } - for (final name in syncResponse.plannedTabNames) { - final hasOpenTab = openTabs.any((t) => t.customerName == name); - if (!hasOpenTab) { - await barTabService.createTab(customerName: name); - } + final openNames = openTabs.map((tab) => tab.customerName).toSet(); + for (final name in response.plannedTabNames) { + if (name.trim().isEmpty || !openNames.add(name)) continue; + await barTabService.createTab(customerName: name); } - if (syncResponse.cursor != null) { - await _saveCursor(syncResponse.cursor!); - } - - await _updateSyncState(success: true); + await _updateSyncState(success: true, cursor: response.cursor); } - Future> _buildSnapshot() async { - final openTabs = await barTabService.getOpenTabs(); - return openTabs.map((tab) => SnapshotTab( + SnapshotTab _toSnapshot(BarTab tab) { + return SnapshotTab( id: tab.id, customerName: tab.customerName, version: tab.version, totalInCents: tab.totalInCents, itemCount: tab.itemCount, - )).toList(); + ); } - Future _processCommand(RemoteCommand command) async { - final alreadyProcessed = await _isCommandProcessed(command.id); - if (alreadyProcessed) return; + Future _processCommand( + RemoteCommand command, + _Credentials credentials, + ) async { + final processed = await _getProcessedCommand(command.id); + if (processed != null) { + // The server only returns pending commands, so the earlier ack never arrived: resend it + await _ackCommand(credentials, command.id, processed.result); + return; + } + final outcome = await _executeCommand(command); + + // Recorded before acking, so a failed ack never re-executes the command. + await _recordProcessed(command, outcome.result); + await _ackCommand(credentials, command.id, outcome.result, outcome.message); + } + + Future<_CommandOutcome> _executeCommand(RemoteCommand command) async { if (command.isExpired) { - await _ackCommand(command.id, 'expired', 'Command expired'); - await _recordProcessed(command.id, command.type, 'expired'); - return; + return (result: CommandResult.expired, message: 'Command expired'); } - if (command.type != 'close_tab') { - await _ackCommand(command.id, 'failed', 'Unknown command type: ${command.type}'); - await _recordProcessed(command.id, command.type, 'failed'); - return; - } + return switch (command.type) { + CommandType.closeTab => _closeTab(command), + null => ( + result: CommandResult.failed, + message: 'Unknown command type: ${command.rawType}', + ), + }; + } + Future<_CommandOutcome> _closeTab(RemoteCommand command) async { try { final tab = await barTabService.getTabById(command.tabId); - if (tab == null) { - await _ackCommand(command.id, 'conflict', 'Tab not found'); - await _recordProcessed(command.id, command.type, 'conflict'); - _eventController.add(const RemoteSyncEvent( - type: RemoteSyncEventType.conflict, - message: 'Tab not found', - )); - return; - } + await barTabService.closeTab( + command.tabId, + paymentMethod: PaymentMethod.fromValue(command.paymentMethod), + expectedVersion: command.expectedVersion, + ); - if (!tab.isOpen) { - await _ackCommand(command.id, 'conflict', 'Tab already closed'); - await _recordProcessed(command.id, command.type, 'conflict'); - _eventController.add(const RemoteSyncEvent( - type: RemoteSyncEventType.conflict, - message: 'Tab already closed', - )); - return; - } + _eventController.add( + RemoteSyncEvent( + type: RemoteSyncEventType.tabClosedRemotely, + tabCustomerName: tab?.customerName, + requestedBy: command.requestedBy, + ), + ); - if (tab.version != command.expectedVersion) { - await _ackCommand(command.id, 'conflict', 'Version mismatch'); - await _recordProcessed(command.id, command.type, 'conflict'); - _eventController.add(const RemoteSyncEvent( - type: RemoteSyncEventType.conflict, - message: 'Tab was modified since the request', - )); - return; - } - - final method = PaymentMethod.fromValue(command.paymentMethod); - await barTabService.closeTab(command.tabId, paymentMethod: method); - - await _ackCommand(command.id, 'completed'); - await _recordProcessed(command.id, command.type, 'completed'); - - _eventController.add(RemoteSyncEvent( - type: RemoteSyncEventType.tabClosedRemotely, - tabCustomerName: tab.customerName, - requestedBy: command.requestedBy, - )); + return (result: CommandResult.completed, message: null); + } on TabNotFoundException { + return _conflict('Tab not found'); + } on TabVersionConflictException { + return _conflict('Tab was modified since the request'); } catch (e, stack) { debugPrint('RemoteSyncService: command processing failed: $e'); - Sentry.captureException(e, stackTrace: stack); - await _ackCommand(command.id, 'failed', e.toString()); - await _recordProcessed(command.id, command.type, 'failed'); + unawaited(Sentry.captureException(e, stackTrace: stack)); + return (result: CommandResult.failed, message: e.toString()); } } - Future?> _httpPost( - String url, { - required Map body, - required String token, - }) async { - final response = await http.post( - Uri.parse(url), - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, - body: jsonEncode(body), - ).timeout(const Duration(seconds: 15)); + _CommandOutcome _conflict(String message) { + _emit(RemoteSyncEventType.conflict, message: message); + return (result: CommandResult.conflict, message: message); + } - if (response.statusCode == 401) { - await secureStorage.delete(key: _authTokenKey); - debugPrint('RemoteSyncService: token revoked'); - return null; - } - - if (response.statusCode == 429) { - final retryAfter = response.headers['retry-after']; - if (retryAfter != null) { - final seconds = int.tryParse(retryAfter) ?? 60; - _applyBackoff(forcedDelay: seconds); - } - return null; - } - - if (response.statusCode < 200 || response.statusCode >= 300) { - debugPrint('RemoteSyncService: HTTP ${response.statusCode}: ${response.body}'); - return null; - } - - if (response.body.isEmpty) return {}; - - return jsonDecode(response.body) as Map; + void _emit(RemoteSyncEventType type, {String? message}) { + _eventController.add(RemoteSyncEvent(type: type, message: message)); } Future _ackCommand( + _Credentials credentials, String commandId, - String status, [ + CommandResult status, [ String? result, - ]) async { - final token = await authToken; - final deviceId = await this.deviceId; - if (token == null || deviceId == null) return; - - try { - final ack = CommandAck(commandId: commandId, status: status, result: result); - await _httpPost( - '$_baseUrl/devices/$deviceId/commands/$commandId/ack', - body: ack.toJson(), - token: token, - ); - } catch (e) { - debugPrint('RemoteSyncService: ack failed: $e'); - } + ]) { + return apiClient.ackCommand( + token: credentials.token, + deviceId: credentials.deviceId, + ack: CommandAck(commandId: commandId, status: status, result: result), + ); } - Future _getSyncState() async { - final query = database.select(database.remoteSyncState) - ..where((row) => row.deviceId.equals('default')); - return query.getSingleOrNull(); + Future _getSyncState() { + return (database.select( + database.remoteSyncState, + )..where((row) => row.deviceId.equals(_syncStateId))).getSingleOrNull(); } - Future _saveCursor(String cursor) async { + Future _updateSyncState({required bool success, String? cursor}) async { + final now = DateTime.now(); + final existing = await _getSyncState(); + await database .into(database.remoteSyncState) .insertOnConflictUpdate( - RemoteSyncStateCompanion( - deviceId: const Value('default'), - serverCursor: Value(cursor), + RemoteSyncStateCompanion.insert( + deviceId: _syncStateId, + serverCursor: Value(cursor ?? existing?.serverCursor), + lastSuccessfulSyncAt: Value( + success ? now : existing?.lastSuccessfulSyncAt, + ), + lastAttemptedSyncAt: Value(now), + consecutiveFailureCount: Value( + success ? 0 : (existing?.consecutiveFailureCount ?? 0) + 1, + ), ), ); } - Future _updateSyncState({required bool success}) async { - final now = DateTime.now(); - final existing = await _getSyncState(); - - if (existing == null) { - await database.into(database.remoteSyncState).insert( - RemoteSyncStateCompanion.insert( - deviceId: 'default', - lastSuccessfulSyncAt: Value(success ? now : null), - lastAttemptedSyncAt: Value(now), - consecutiveFailureCount: Value(success ? 0 : 1), - ), - ); - } else { - await (database.update(database.remoteSyncState) - ..where((row) => row.deviceId.equals('default'))) - .write(RemoteSyncStateCompanion( - lastSuccessfulSyncAt: Value(success ? now : existing.lastSuccessfulSyncAt), - lastAttemptedSyncAt: Value(now), - consecutiveFailureCount: Value( - success ? 0 : existing.consecutiveFailureCount + 1, - ), - )); - } - } - - Future _isCommandProcessed(String commandId) async { - final query = database.select(database.processedRemoteCommands) - ..where((row) => row.commandId.equals(commandId)); - final result = await query.getSingleOrNull(); - return result != null; + Future _getProcessedCommand(String commandId) { + return (database.select( + database.processedRemoteCommands, + )..where((row) => row.commandId.equals(commandId))).getSingleOrNull(); } Future _recordProcessed( - String commandId, - String commandType, - String result, + RemoteCommand command, + CommandResult result, ) async { await database .into(database.processedRemoteCommands) .insertOnConflictUpdate( ProcessedRemoteCommandsCompanion.insert( - commandId: commandId, - commandType: commandType, + commandId: command.id, + commandType: command.rawType, processedAt: DateTime.now(), result: result, ), ); } - void _applyBackoff({int? forcedDelay}) { - final delay = forcedDelay ?? _calculateBackoff(); - _syncTimer?.cancel(); - _syncTimer = Timer(Duration(seconds: delay), () => _onTick()); - } - - int _calculateBackoff() { - if (_consecutiveFailures <= 1) return 90; - final backoff = 90 * (1 << (_consecutiveFailures - 1).clamp(0, 3)); - return backoff.clamp(90, 600); - } - Future setCredentials({ required String deviceId, required String token, required String venueId, - String? serverUrl, }) async { await secureStorage.write(key: _deviceIdKey, value: deviceId); await secureStorage.write(key: _authTokenKey, value: token); await secureStorage.write(key: _venueIdKey, value: venueId); - if (serverUrl != null) { - await secureStorage.write(key: _serverUrlKey, value: serverUrl); - } } Future clearCredentials() async { await secureStorage.delete(key: _deviceIdKey); await secureStorage.delete(key: _authTokenKey); - await secureStorage.delete(key: _serverUrlKey); await secureStorage.delete(key: _venueIdKey); } @@ -399,20 +396,15 @@ class RemoteSyncService { return state?.lastSuccessfulSyncAt; } - Future get lastFailureCount async { - final state = await _getSyncState(); - return state?.consecutiveFailureCount ?? 0; - } - - Future> getRecentActivity() async { - final query = database.select(database.processedRemoteCommands) - ..orderBy([(row) => OrderingTerm.desc(row.processedAt)]) - ..limit(50); - return query.get(); + Future> getRecentActivity() { + return (database.select(database.processedRemoteCommands) + ..orderBy([(row) => OrderingTerm.desc(row.processedAt)]) + ..limit(50)) + .get(); } void dispose() { - _syncTimer?.cancel(); + stopPeriodicSync(); _eventController.close(); } -} \ No newline at end of file +} diff --git a/lib/utils/app_config.dart b/lib/utils/app_config.dart index bc427a7..6d87fec 100644 --- a/lib/utils/app_config.dart +++ b/lib/utils/app_config.dart @@ -1,6 +1,11 @@ -const kUpdateServerUrl = 'https://updater.brammie15.dev'; +const kUpdateServerUrl = 'https://updater.kooltap.com'; const kExportServerUrl = 'https://kooltab.brammie15.dev/api/backup'; const kRemoteServerUrl = 'https://api.kooltap.com/api/v1'; -const kSyncPollingIntervalSeconds = 90; +// Idle sync interval; also the longest a close request from the dashboard waits +const kSyncPollingIntervalSeconds = 15; +// After a tab changes, sync once things are quiet for this long... +const kSyncChangeDebounceMs = 2000; +// ...but never wait longer than this while changes keep coming +const kSyncChangeMaxDelayMs = 10000; const kSyncMaxBackoffSeconds = 600; -const kCommandExpiryHours = 24; \ No newline at end of file +const kCommandExpiryHours = 24; diff --git a/lib/utils/app_update_util.dart b/lib/utils/app_update_util.dart index be1ae1c..8198d2c 100644 --- a/lib/utils/app_update_util.dart +++ b/lib/utils/app_update_util.dart @@ -24,12 +24,12 @@ class UpdateInfo { factory UpdateInfo.fromJson(Map json) { return UpdateInfo( - update: json["update"] ?? false, - version: json["version"] ?? "", - notes: json["notes"] ?? "", - mandatory: json["mandatory"] ?? false, - sha256: json["sha256"] ?? "", - download: json["download"] ?? "", + update: json['update'] ?? false, + version: json['version'] ?? '', + notes: json['notes'] ?? '', + mandatory: json['mandatory'] ?? false, + sha256: json['sha256'] ?? '', + download: json['download'] ?? '', ); } @@ -56,26 +56,26 @@ class AppUpdateUtil { Future currentVersion() async { final info = await PackageInfo.fromPlatform(); - debugPrint("Current app version: ${info.version}"); + debugPrint('Current app version: ${info.version}'); return info.version; } Future checkForUpdate() async { final version = await currentVersion(); - final url = Uri.parse("$serverUrl/api/update?version=$version"); + final url = Uri.parse('$serverUrl/api/update?version=$version'); final response = await http.get(url); - debugPrint("Response from update server: ${response.body}"); + debugPrint('Response from update server: ${response.body}'); if (response.statusCode != 200) { - throw Exception("Update server unavailable"); + throw Exception('Update server unavailable'); } final data = jsonDecode(response.body); - if (data["update"] != true) { + if (data['update'] != true) { return null; } diff --git a/lib/utils/app_updater.dart b/lib/utils/app_updater.dart index 54565c3..b4ccfbb 100644 --- a/lib/utils/app_updater.dart +++ b/lib/utils/app_updater.dart @@ -2,10 +2,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:kooltab2/utils/app_update_util.dart'; -import 'app_config.dart'; import '../l10n/app_localizations.dart'; +import 'app_config.dart'; +import 'app_update_util.dart'; class UpdateChecker { static bool _hasChecked = false; @@ -13,7 +13,7 @@ class UpdateChecker { static Future check(BuildContext context) async { if (!Platform.isAndroid) return; if (_hasChecked) { - debugPrint("Update already checked this boot"); + debugPrint('Update already checked this boot'); return; } @@ -25,17 +25,17 @@ class UpdateChecker { final update = await updater.checkForUpdate(); if (update == null) { - debugPrint("No update available"); + debugPrint('No update available'); return; } - debugPrint("Update available: ${update.version}"); + debugPrint('Update available: ${update.version}'); if (!context.mounted) return; _showUpdateDialog(context, update); } catch (e) { - debugPrint("Update check failed: $e"); + debugPrint('Update check failed: $e'); } } diff --git a/lib/utils/collection_utils.dart b/lib/utils/collection_utils.dart deleted file mode 100644 index 7181695..0000000 --- a/lib/utils/collection_utils.dart +++ /dev/null @@ -1,9 +0,0 @@ -bool listEquals(List? a, List? b) { - if (identical(a, b)) return true; - if (a == null || b == null) return a == b; - if (a.length != b.length) return false; - for (int i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; -} \ No newline at end of file diff --git a/lib/utils/money.dart b/lib/utils/money.dart new file mode 100644 index 0000000..5c98cbe --- /dev/null +++ b/lib/utils/money.dart @@ -0,0 +1,7 @@ +import 'package:intl/intl.dart'; + +final _euro = NumberFormat.currency(locale: 'nl_BE', symbol: '€'); + +extension Cents on int { + String get asEuro => _euro.format(this / 100); +} diff --git a/lib/viewmodels/admin_pin_view_model.dart b/lib/viewmodels/admin_pin_view_model.dart index 1b1f0e1..87edcf8 100644 --- a/lib/viewmodels/admin_pin_view_model.dart +++ b/lib/viewmodels/admin_pin_view_model.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; +import '../models/app_error.dart'; import '../services/admin_pin_service.dart'; +import '../services/pin_lock_service.dart'; class AdminPinViewModel extends ChangeNotifier { final AdminPinService adminPinService; @@ -11,7 +13,7 @@ class AdminPinViewModel extends ChangeNotifier { bool _isUnlocked = false; bool _isLoading = false; bool _hasLoaded = false; - String? _errorMessage; + AppError? _error; bool get isPinSet => _isPinSet; @@ -21,7 +23,7 @@ class AdminPinViewModel extends ChangeNotifier { bool get hasLoaded => _hasLoaded; - String? get errorMessage => _errorMessage; + AppError? get error => _error; Future ensureLoaded() async { if (_hasLoaded || _isLoading) return; @@ -33,14 +35,14 @@ class AdminPinViewModel extends ChangeNotifier { if (_isLoading) return; _isLoading = true; - _errorMessage = null; + _error = null; notifyListeners(); try { _isPinSet = await adminPinService.hasPin(); } catch (e) { debugPrint('AdminPinViewModel: load error: $e'); - _errorMessage = 'Could not check PIN status.'; + _error = AppError.checkPinStatus; } finally { _hasLoaded = true; _isLoading = false; @@ -49,7 +51,7 @@ class AdminPinViewModel extends ChangeNotifier { } Future setPin(String pin) async { - _errorMessage = null; + _error = null; try { await adminPinService.setPin(pin); @@ -59,14 +61,14 @@ class AdminPinViewModel extends ChangeNotifier { return true; } catch (e) { debugPrint('AdminPinViewModel: setPin error: $e'); - _errorMessage = 'Could not save PIN.'; + _error = AppError.savePin; notifyListeners(); return false; } } Future verify(String pin) async { - _errorMessage = null; + _error = null; try { final matches = await adminPinService.verifyPin(pin); @@ -77,12 +79,16 @@ class AdminPinViewModel extends ChangeNotifier { return true; } - _errorMessage = 'Incorrect PIN.'; + _error = AppError.incorrectPin; + notifyListeners(); + return false; + } on PinLockedOutException { + _error = AppError.tooManyPinAttempts; notifyListeners(); return false; } catch (e) { debugPrint('AdminPinViewModel: verify error: $e'); - _errorMessage = 'Could not verify PIN.'; + _error = AppError.verifyPin; notifyListeners(); return false; } @@ -92,17 +98,23 @@ class AdminPinViewModel extends ChangeNotifier { required String currentPin, required String newPin, }) async { - final currentMatches = await adminPinService.verifyPin(currentPin); - - if (!currentMatches) { - _errorMessage = 'Current PIN is incorrect.'; - notifyListeners(); - return false; - } + if (!await _verifyCurrentPin(currentPin)) return false; return setPin(newPin); } + Future _verifyCurrentPin(String pin) async { + try { + if (await adminPinService.verifyPin(pin)) return true; + _error = AppError.currentPinIncorrect; + } on PinLockedOutException { + _error = AppError.tooManyPinAttempts; + } + + notifyListeners(); + return false; + } + void lock() { if (!_isUnlocked) return; diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index b9f9976..29f259c 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -1,114 +1,68 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import '../models/payment_method.dart'; +import '../models/app_error.dart'; import '../models/bar_tab.dart'; +import '../models/payment_method.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; -import '../models/remote_command.dart'; import '../services/bar_tab_service.dart'; import '../services/product_service.dart'; -import 'inventory_view_model.dart'; -import 'remote_sync_view_model.dart'; +/// Tabs come from a database stream, so every change (local or remote) shows +/// up without manual reloads. Actions report to Sentry and rethrow; the view +/// decides how to tell the user. class BarScreenViewModel extends ChangeNotifier { final BarTabService barTabService; - final InventoryViewModel inventory; - final RemoteSyncViewModel remoteSyncViewModel; - BarScreenViewModel({ - required this.barTabService, - required this.inventory, - required this.remoteSyncViewModel, - }) { - _eventSubscription = remoteSyncViewModel.syncService.events.listen(_onRemoteEvent); - } - - StreamSubscription? _eventSubscription; - - RemoteSyncEvent? _lastRemoteEvent; - RemoteSyncEvent? get lastRemoteEvent => _lastRemoteEvent; + BarScreenViewModel({required this.barTabService}); + StreamSubscription>? _subscription; List _tabs = []; String? _selectedTabId; - bool _isLoading = false; bool _hasLoaded = false; - String? _errorMessage; + AppError? _loadError; List get tabs => _tabs; String? get selectedTabId => _selectedTabId; - bool get isLoading => _isLoading; + bool get isLoading => !_hasLoaded; - bool get hasLoaded => _hasLoaded; + AppError? get loadError => _loadError; - String? get errorMessage => _errorMessage; + BarTab? get selectedTab => + _tabs.firstWhereOrNull((tab) => tab.id == _selectedTabId); - BarTab? get selectedTab { - if (_selectedTabId == null) return null; + void ensureLoaded() { + if (_subscription != null) return; - try { - return _tabs.firstWhere((tab) => tab.id == _selectedTabId); - } catch (e) { - debugPrint('BarScreenViewModel: error finding selected tab: $e'); - return null; - } + _subscription = barTabService.watchOpenTabs().listen( + _onTabs, + onError: _onLoadError, + ); } - void _onRemoteEvent(RemoteSyncEvent event) { - _lastRemoteEvent = event; + void _onTabs(List tabs) { + _tabs = tabs; + _hasLoaded = true; + _loadError = null; + + if (selectedTab == null) _selectedTabId = tabs.firstOrNull?.id; + notifyListeners(); - if (event.type == RemoteSyncEventType.tabClosedRemotely) { - _reloadTabs(); - } } - void clearRemoteEvent() { - _lastRemoteEvent = null; - } + void _onLoadError(Object error, StackTrace stack) { + debugPrint('BarScreenViewModel: load error: $error'); + unawaited(Sentry.captureException(error, stackTrace: stack)); - @override - void dispose() { - _eventSubscription?.cancel(); - super.dispose(); - } - - Future ensureLoaded() async { - if (_hasLoaded || _isLoading) return; - - await load(); - } - - Future load() async { - if (_isLoading) return; - - _isLoading = true; - _errorMessage = null; + _hasLoaded = true; + _loadError = AppError.loadBar; notifyListeners(); - - try { - _tabs = await barTabService.getOpenTabs(); - - if (_selectedTabId == null || - !_tabs.any((tab) => tab.id == _selectedTabId)) { - _selectedTabId = _tabs.isEmpty ? null : _tabs.first.id; - } - } catch (e) { - debugPrint('BarScreenViewModel: load error: $e'); - _errorMessage = 'Could not load bar screen.'; - } finally { - _hasLoaded = true; - _isLoading = false; - notifyListeners(); - } - } - - Future loadAndSync() async { - await load(); - await remoteSyncViewModel.syncNow(); } void selectTab(String tabId) { @@ -116,143 +70,72 @@ class BarScreenViewModel extends ChangeNotifier { notifyListeners(); } - Future createTab(String customerName) async { - try { + Future createTab(String customerName) { + return _guard('createTab', () async { final tab = await barTabService.createTab(customerName: customerName); - _selectedTabId = tab.id; - await _reloadTabs(); - } catch (e, stack) { - debugPrint('BarScreenViewModel: createTab error: $e'); - _errorMessage = 'Could not create tab.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - } + selectTab(tab.id); + }); } - Future renameTab(String tabId, String customerName) async { - try { - await barTabService.renameTab(tabId: tabId, customerName: customerName); - await _reloadTabs(); - } catch (e, stack) { - debugPrint('BarScreenViewModel: renameTab error: $e'); - _errorMessage = 'Could not rename tab.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } + Future renameTab(String tabId, String customerName) { + return _guard( + 'renameTab', + () => barTabService.renameTab(tabId: tabId, customerName: customerName), + ); } - Future deleteTab(String tabId) async { - try { - final deletedItems = await barTabService.deleteTab(tabId); - - for (final item in deletedItems) { - inventory.applyStockDelta(item.productId, item.quantity); - } - - await _reloadTabs(); - } catch (e, stack) { - debugPrint('BarScreenViewModel: deleteTab error: $e'); - _errorMessage = 'Could not delete tab.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } + Future deleteTab(String tabId) { + return _guard('deleteTab', () => barTabService.deleteTab(tabId)); } Future addProductToSelectedTab(Product product) async { final tab = selectedTab; - if (tab == null) return; - try { - await barTabService.addProductToTab(tabId: tab.id, product: product); - inventory.applyStockDelta(product.id, -1); - - await _reloadTabs(); - } catch (e, stack) { - if (e is InsufficientStockException) rethrow; - - debugPrint('BarScreenViewModel: addProductToSelectedTab error: $e'); - _errorMessage = 'Could not add product to tab.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } + await _guard( + 'addProductToSelectedTab', + () => barTabService.addProductToTab(tabId: tab.id, product: product), + ); } Future changeItemQuantity(TabItem item, int delta) async { if (delta == 0) return; - try { - final actualDelta = await barTabService.adjustTabItemQuantity( - tabItemId: item.id, - delta: delta, - ); - inventory.applyStockDelta(item.productId, -actualDelta); - - await _reloadTabs(); - } catch (e, stack) { - if (e is InsufficientStockException) rethrow; - - debugPrint('BarScreenViewModel: changeItemQuantity error: $e'); - _errorMessage = 'Could not update item quantity.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } + await _guard( + 'changeItemQuantity', + () => + barTabService.adjustTabItemQuantity(tabItemId: item.id, delta: delta), + ); } Future closeSelectedTab({ PaymentMethod paymentMethod = PaymentMethod.cash, }) async { final tab = selectedTab; - if (tab == null) return; + await _guard( + 'closeSelectedTab', + () => barTabService.closeTab(tab.id, paymentMethod: paymentMethod), + ); + } + + /// Running out of stock is an expected outcome, not a bug worth reporting. + Future _guard(String action, Future Function() body) async { try { - await barTabService.closeTab(tab.id, paymentMethod: paymentMethod); - await _reloadTabs(); + return await body(); + } on InsufficientStockException { + rethrow; } catch (e, stack) { - debugPrint('BarScreenViewModel: closeSelectedTab error: $e'); - _errorMessage = 'Could not close tab.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); + debugPrint('BarScreenViewModel: $action error: $e'); + unawaited(Sentry.captureException(e, stackTrace: stack)); rethrow; } } - Future closeTab( - String tabId, { - PaymentMethod paymentMethod = PaymentMethod.cash, - }) async { - try { - await barTabService.closeTab(tabId, paymentMethod: paymentMethod); - await _reloadTabs(); - } catch (e, stack) { - debugPrint('BarScreenViewModel: closeTab error: $e'); - _errorMessage = 'Could not close tab.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } - } - - Future _reloadTabs() async { - try { - _tabs = await barTabService.getOpenTabs(); - - if (_selectedTabId == null || - !_tabs.any((tab) => tab.id == _selectedTabId)) { - _selectedTabId = _tabs.isEmpty ? null : _tabs.first.id; - } - - notifyListeners(); - } catch (e, stack) { - debugPrint('BarScreenViewModel: reloadTabs error: $e'); - _errorMessage = 'Could not reload tabs.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - } + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); } } diff --git a/lib/viewmodels/dev_menu_view_model.dart b/lib/viewmodels/dev_menu_view_model.dart index 3a8ffb6..79f0454 100644 --- a/lib/viewmodels/dev_menu_view_model.dart +++ b/lib/viewmodels/dev_menu_view_model.dart @@ -1,19 +1,21 @@ -import 'dart:math'; +import 'dart:async'; import 'dart:io'; +import 'dart:math'; -import 'package:flutter/foundation.dart'; import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:kooltab2/database/app_database.dart'; -import 'package:kooltab2/services/bar_tab_service.dart'; -import 'package:kooltab2/models/payment_method.dart'; -import 'package:kooltab2/services/default_product_seeder.dart'; -import 'package:kooltab2/services/pin_lock_service.dart'; -import 'package:kooltab2/services/product_service.dart'; -import 'package:kooltab2/services/settings_service.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:uuid/uuid.dart'; +import '../database/app_database.dart'; +import '../models/payment_method.dart'; +import '../services/bar_tab_service.dart'; +import '../services/default_product_seeder.dart'; +import '../services/pin_lock_service.dart'; +import '../services/product_service.dart'; +import '../services/settings_service.dart'; + class DevMenuViewModel extends ChangeNotifier { final AppDatabase database; final ProductService productService; @@ -41,6 +43,7 @@ class DevMenuViewModel extends ChangeNotifier { try { await database.transaction(() async { + await database.delete(database.tabItemPurchases).go(); await database.delete(database.tabItems).go(); await database.delete(database.barTabs).go(); }); @@ -73,6 +76,7 @@ class DevMenuViewModel extends ChangeNotifier { try { await database.transaction(() async { + await database.delete(database.tabItemPurchases).go(); await database.delete(database.tabItems).go(); await database.delete(database.products).go(); }); @@ -103,6 +107,7 @@ class DevMenuViewModel extends ChangeNotifier { try { await database.transaction(() async { + await database.delete(database.tabItemPurchases).go(); await database.delete(database.tabItems).go(); await database.delete(database.products).go(); }); @@ -136,7 +141,7 @@ class DevMenuViewModel extends ChangeNotifier { _lastAction = 'Product images cleared'; } catch (e, stack) { debugPrint('DevMenuViewModel: clearProductImages error: $e'); - Sentry.captureException(e, stackTrace: stack); + unawaited(Sentry.captureException(e, stackTrace: stack)); _lastAction = 'Failed to clear images: $e'; } finally { _isLoading = false; @@ -327,7 +332,7 @@ class DevMenuViewModel extends ChangeNotifier { try { throw Exception('Test GlitchTip error!'); } catch (exception, stackTrace) { - Sentry.captureException(exception, stackTrace: stackTrace); + unawaited(Sentry.captureException(exception, stackTrace: stackTrace)); } } @@ -422,7 +427,7 @@ class DevMenuViewModel extends ChangeNotifier { customerName: customer, closedAt: closedAt, paymentMethod: Value( - paymentMethods[random.nextInt(paymentMethods.length)].value, + paymentMethods[random.nextInt(paymentMethods.length)], ), ), ); diff --git a/lib/viewmodels/history_view_model.dart b/lib/viewmodels/history_view_model.dart index b6f3a50..c8805f3 100644 --- a/lib/viewmodels/history_view_model.dart +++ b/lib/viewmodels/history_view_model.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; +import '../models/app_error.dart'; import '../models/bar_tab.dart'; import '../models/closed_tab.dart'; import '../services/bar_tab_service.dart'; @@ -20,7 +21,7 @@ class HistoryViewModel extends ChangeNotifier { bool _isLoading = false; bool _hasLoaded = false; bool _isLoadingMore = false; - String? _errorMessage; + AppError? _error; String _searchQuery = ''; List get closedTabs { @@ -47,7 +48,7 @@ class HistoryViewModel extends ChangeNotifier { bool get hasMore => _closedTabs.length < _totalCount; - String? get errorMessage => _errorMessage; + AppError? get error => _error; Future ensureLoaded() async { if (_hasLoaded || _isLoading) return; @@ -59,7 +60,7 @@ class HistoryViewModel extends ChangeNotifier { if (_isLoading) return; _isLoading = true; - _errorMessage = null; + _error = null; notifyListeners(); try { @@ -81,7 +82,7 @@ class HistoryViewModel extends ChangeNotifier { _offset = _closedTabs.length; } catch (e) { debugPrint('HistoryViewModel: load error: $e'); - _errorMessage = 'Could not load tab history.'; + _error = AppError.loadHistory; } finally { _hasLoaded = true; _isLoading = false; @@ -101,7 +102,7 @@ class HistoryViewModel extends ChangeNotifier { _offset = _closedTabs.length; } catch (e) { debugPrint('HistoryViewModel: loadMore error: $e'); - _errorMessage = 'Could not load more tabs.'; + _error = AppError.loadMoreTabs; } finally { _isLoadingMore = false; notifyListeners(); diff --git a/lib/viewmodels/inventory_view_model.dart b/lib/viewmodels/inventory_view_model.dart index a9194b0..5228160 100644 --- a/lib/viewmodels/inventory_view_model.dart +++ b/lib/viewmodels/inventory_view_model.dart @@ -1,80 +1,60 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import '../models/app_error.dart'; import '../models/product.dart'; import '../services/product_service.dart'; +/// The app-wide product list, kept in sync with the database. Stock changes +/// from tabs show up here automatically. class InventoryViewModel extends ChangeNotifier { final ProductService productService; InventoryViewModel({required this.productService}); + StreamSubscription>? _subscription; List _products = []; + bool _hasLoaded = false; + AppError? _loadError; List get products => _products; - Future load() async { - try { - _products = await productService.getProducts(); - notifyListeners(); - } catch (e, stack) { - debugPrint('InventoryViewModel: load error: $e'); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } - } + bool get hasLoaded => _hasLoaded; - Future decreaseStock(String productId, int amount) async { - try { - await productService.decreaseStock(productId, amount); + bool get isLoading => !_hasLoaded; - final index = _products.indexWhere((product) => product.id == productId); + AppError? get loadError => _loadError; - if (index != -1) { - _products[index] = _products[index].copyWith( - stockQuantity: _products[index].stockQuantity - amount, - ); - } + void ensureLoaded() { + if (_subscription != null) return; - notifyListeners(); - } catch (e, stack) { - if (e is InsufficientStockException) rethrow; - - debugPrint('InventoryViewModel: decreaseStock error: $e'); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } - } - - Future increaseStock(String productId, int amount) async { - try { - await productService.increaseStock(productId, amount); - - final index = _products.indexWhere((product) => product.id == productId); - - if (index != -1) { - _products[index] = _products[index].copyWith( - stockQuantity: _products[index].stockQuantity + amount, - ); - } - - notifyListeners(); - } catch (e, stack) { - debugPrint('InventoryViewModel: increaseStock error: $e'); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } - } - - void applyStockDelta(String productId, int delta) { - if (delta == 0) return; - - final index = _products.indexWhere((product) => product.id == productId); - if (index == -1) return; - - _products[index] = _products[index].copyWith( - stockQuantity: _products[index].stockQuantity + delta, + _subscription = productService.watchProducts().listen( + _onProducts, + onError: _onLoadError, ); + } + + void _onProducts(List products) { + _products = products; + _hasLoaded = true; + _loadError = null; notifyListeners(); } + + void _onLoadError(Object error, StackTrace stack) { + debugPrint('InventoryViewModel: load error: $error'); + unawaited(Sentry.captureException(error, stackTrace: stack)); + + _hasLoaded = true; + _loadError = AppError.loadProducts; + notifyListeners(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } } diff --git a/lib/viewmodels/pin_lock_view_model.dart b/lib/viewmodels/pin_lock_view_model.dart index 4badc40..b7241e3 100644 --- a/lib/viewmodels/pin_lock_view_model.dart +++ b/lib/viewmodels/pin_lock_view_model.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; +import '../models/app_error.dart'; import '../services/pin_lock_service.dart'; import '../services/settings_service.dart'; @@ -14,7 +15,7 @@ class PinLockViewModel extends ChangeNotifier { bool _isLoading = false; bool _hasLoaded = false; bool _pinRequired = true; - String? _errorMessage; + AppError? _error; bool get isPinSet => _isPinSet; @@ -26,7 +27,7 @@ class PinLockViewModel extends ChangeNotifier { bool get pinRequired => _pinRequired; - String? get errorMessage => _errorMessage; + AppError? get error => _error; void setPinRequired(bool value) { if (_pinRequired == value) return; @@ -45,7 +46,7 @@ class PinLockViewModel extends ChangeNotifier { if (_isLoading) return; _isLoading = true; - _errorMessage = null; + _error = null; notifyListeners(); try { @@ -58,7 +59,7 @@ class PinLockViewModel extends ChangeNotifier { } } catch (e) { debugPrint('PinLockViewModel: load error: $e'); - _errorMessage = 'Could not check PIN status.'; + _error = AppError.checkPinStatus; } finally { _hasLoaded = true; _isLoading = false; @@ -67,7 +68,7 @@ class PinLockViewModel extends ChangeNotifier { } Future setPin(String pin) async { - _errorMessage = null; + _error = null; try { await pinLockService.setPin(pin); @@ -85,14 +86,14 @@ class PinLockViewModel extends ChangeNotifier { return true; } catch (e) { debugPrint('PinLockViewModel: setPin error: $e'); - _errorMessage = 'Could not save PIN.'; + _error = AppError.savePin; notifyListeners(); return false; } } Future verify(String pin) async { - _errorMessage = null; + _error = null; try { final matches = await pinLockService.verifyPin(pin); @@ -103,12 +104,16 @@ class PinLockViewModel extends ChangeNotifier { return true; } - _errorMessage = 'Incorrect PIN.'; + _error = AppError.incorrectPin; + notifyListeners(); + return false; + } on PinLockedOutException { + _error = AppError.tooManyPinAttempts; notifyListeners(); return false; } catch (e) { debugPrint('PinLockViewModel: verify error: $e'); - _errorMessage = 'Could not verify PIN.'; + _error = AppError.verifyPin; notifyListeners(); return false; } @@ -118,25 +123,13 @@ class PinLockViewModel extends ChangeNotifier { required String currentPin, required String newPin, }) async { - final currentMatches = await pinLockService.verifyPin(currentPin); - - if (!currentMatches) { - _errorMessage = 'Current PIN is incorrect.'; - notifyListeners(); - return false; - } + if (!await _verifyCurrentPin(currentPin)) return false; return setPin(newPin); } Future disablePin(String currentPin) async { - final matches = await pinLockService.verifyPin(currentPin); - - if (!matches) { - _errorMessage = 'Current PIN is incorrect.'; - notifyListeners(); - return false; - } + if (!await _verifyCurrentPin(currentPin)) return false; await pinLockService.clearPin(); _isPinSet = false; @@ -152,6 +145,18 @@ class PinLockViewModel extends ChangeNotifier { return true; } + Future _verifyCurrentPin(String pin) async { + try { + if (await pinLockService.verifyPin(pin)) return true; + _error = AppError.currentPinIncorrect; + } on PinLockedOutException { + _error = AppError.tooManyPinAttempts; + } + + notifyListeners(); + return false; + } + void lock() { if (!_isUnlocked) return; diff --git a/lib/viewmodels/product_list_view_model.dart b/lib/viewmodels/product_list_view_model.dart index 4a3de57..84e55b8 100644 --- a/lib/viewmodels/product_list_view_model.dart +++ b/lib/viewmodels/product_list_view_model.dart @@ -1,15 +1,25 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import '../models/app_error.dart'; import '../models/product.dart'; import '../services/product_service.dart'; import 'inventory_view_model.dart'; +/// Product management on top of [InventoryViewModel]. The list refreshes by +/// itself through the inventory stream, so actions only have to write. class ProductListViewModel extends ChangeNotifier { final ProductService productService; final InventoryViewModel inventory; - ProductListViewModel({required this.productService, required this.inventory}); + ProductListViewModel({ + required this.productService, + required this.inventory, + }) { + inventory.addListener(notifyListeners); + } static const List _defaultCategories = [ 'Bier', @@ -22,42 +32,13 @@ class ProductListViewModel extends ChangeNotifier { 'Other', ]; - bool _isLoading = false; - bool _hasLoaded = false; - String? _errorMessage; - List get products => inventory.products; - bool get isLoading => _isLoading; + bool get isLoading => inventory.isLoading; - bool get hasLoaded => _hasLoaded; + AppError? get loadError => inventory.loadError; - String? get errorMessage => _errorMessage; - - Future ensureLoaded() async { - if (_hasLoaded || _isLoading) return; - - await loadProducts(); - } - - Future loadProducts() async { - if (_isLoading) return; - - _isLoading = true; - _errorMessage = null; - notifyListeners(); - - try { - await inventory.load(); - } catch (e) { - debugPrint('ProductListViewModel: load error: $e'); - _errorMessage = 'Could not load products.'; - } finally { - _hasLoaded = true; - _isLoading = false; - notifyListeners(); - } - } + void ensureLoaded() => inventory.ensureLoaded(); Future addProduct({ required String name, @@ -66,67 +47,58 @@ class ProductListViewModel extends ChangeNotifier { required int lowStockThreshold, required int priceInCents, required String? imagePath, - }) async { - try { - await productService.createProduct( + }) { + return _guard( + 'addProduct', + () => productService.createProduct( name: name, category: category, stockQuantity: stockQuantity, lowStockThreshold: lowStockThreshold, priceInCents: priceInCents, imagePath: imagePath, - ); - - await loadProducts(); - } catch (e, stack) { - debugPrint('ProductListViewModel: addProduct error: $e'); - _errorMessage = 'Could not add product.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } + ), + ); } - Future updateProduct(Product product) async { - try { - await productService.updateProduct(product); - await loadProducts(); - } catch (e, stack) { - debugPrint('ProductListViewModel: updateProduct error: $e'); - _errorMessage = 'Could not update product.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } + Future updateProduct(Product product) { + return _guard('updateProduct', () => productService.updateProduct(product)); } - Future deleteProduct(String id) async { - try { - await productService.deleteProduct(id); - await loadProducts(); - } catch (e, stack) { - debugPrint('ProductListViewModel: deleteProduct error: $e'); - _errorMessage = 'Could not delete product.'; - notifyListeners(); - Sentry.captureException(e, stackTrace: stack); - rethrow; - } + Future deleteProduct(String id) { + return _guard('deleteProduct', () => productService.deleteProduct(id)); } Future getProductById(String id) { - return productService.getProductById(id); + return _guard('getProductById', () => productService.getProductById(id)); } List get categories { final categories = { ..._defaultCategories, ...inventory.products - .map((p) => p.category.trim()) - .where((c) => c.isNotEmpty), + .map((product) => product.category.trim()) + .where((category) => category.isNotEmpty), }.toList(); categories.sort(); return categories; } + + Future _guard(String action, Future Function() body) async { + try { + return await body(); + } catch (e, stack) { + debugPrint('ProductListViewModel: $action error: $e'); + unawaited(Sentry.captureException(e, stackTrace: stack)); + rethrow; + } + } + + @override + void dispose() { + inventory.removeListener(notifyListeners); + super.dispose(); + } } diff --git a/lib/viewmodels/registration_view_model.dart b/lib/viewmodels/registration_view_model.dart index 7e7c65e..5c84856 100644 --- a/lib/viewmodels/registration_view_model.dart +++ b/lib/viewmodels/registration_view_model.dart @@ -1,18 +1,23 @@ -import 'dart:convert'; - import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; import '../models/venue.dart'; +import '../services/kooltap_api_client.dart'; import '../services/remote_sync_service.dart'; -import '../utils/app_config.dart'; -enum RegistrationStep { initial, loadingVenues, venuesLoaded, error, registering, registered } +enum RegistrationStep { + initial, + loadingVenues, + venuesLoaded, + error, + registering, + registered, +} class RegistrationViewModel extends ChangeNotifier { + final KoolTapApiClient apiClient; final RemoteSyncService syncService; - RegistrationViewModel({required this.syncService}); + RegistrationViewModel({required this.apiClient, required this.syncService}); RegistrationStep _step = RegistrationStep.initial; RegistrationStep get step => _step; @@ -23,6 +28,7 @@ class RegistrationViewModel extends ChangeNotifier { Venue? _selectedVenue; Venue? get selectedVenue => _selectedVenue; + /// Server-provided, so shown as-is. String? _errorMessage; String? get errorMessage => _errorMessage; @@ -37,21 +43,9 @@ class RegistrationViewModel extends ChangeNotifier { notifyListeners(); try { - final response = await http - .get(Uri.parse('$kRemoteServerUrl/venues/')) - .timeout(const Duration(seconds: 15)); - - if (response.statusCode == 200) { - final List data = jsonDecode(response.body) as List; - _venues = data - .map((e) => Venue.fromJson(e as Map)) - .toList(); - _step = RegistrationStep.venuesLoaded; - } else { - _errorMessage = 'Failed to load venues (HTTP ${response.statusCode})'; - _step = RegistrationStep.error; - } - } catch (e) { + _venues = await apiClient.getVenues(); + _step = RegistrationStep.venuesLoaded; + } on Exception catch (e) { debugPrint('RegistrationViewModel: fetchVenues error: $e'); _errorMessage = e.toString(); _step = RegistrationStep.error; @@ -72,58 +66,36 @@ class RegistrationViewModel extends ChangeNotifier { notifyListeners(); try { - final response = await http - .post( - Uri.parse('$kRemoteServerUrl/devices/register'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'venueId': venue.id, - 'name': deviceName, - 'registrationCode': registrationCode, - }), - ) - .timeout(const Duration(seconds: 15)); + final credentials = await apiClient.registerDevice( + venueId: venue.id, + deviceName: deviceName, + registrationCode: registrationCode, + ); - if (response.statusCode == 201) { - final data = jsonDecode(response.body) as Map; - final deviceId = data['deviceId'] as String; - final token = data['token'] as String; + await syncService.setCredentials( + deviceId: credentials.deviceId, + token: credentials.token, + venueId: venue.id, + ); - await syncService.setCredentials( - deviceId: deviceId, - token: token, - venueId: venue.id, - serverUrl: kRemoteServerUrl, - ); - - _step = RegistrationStep.registered; - notifyListeners(); - return true; - } else { - String message; - try { - final errorData = jsonDecode(response.body) as Map; - message = errorData['message'] as String? ?? 'Registration failed'; - } catch (_) { - message = 'Registration failed (HTTP ${response.statusCode})'; - } - _errorMessage = message; - _step = RegistrationStep.venuesLoaded; - notifyListeners(); - return false; - } - } catch (e) { + _step = RegistrationStep.registered; + notifyListeners(); + return true; + } on ApiException catch (e) { + _errorMessage = e.message.isEmpty + ? 'Registration failed (HTTP ${e.statusCode})' + : e.message; + } on Exception catch (e) { debugPrint('RegistrationViewModel: registerDevice error: $e'); _errorMessage = e.toString(); - _step = RegistrationStep.venuesLoaded; - notifyListeners(); - return false; } + + _step = RegistrationStep.venuesLoaded; + notifyListeners(); + return false; } - Future retry() async { - await fetchVenues(); - } + Future retry() => fetchVenues(); void reset() { _step = RegistrationStep.initial; @@ -132,4 +104,4 @@ class RegistrationViewModel extends ChangeNotifier { _errorMessage = null; notifyListeners(); } -} \ No newline at end of file +} diff --git a/lib/viewmodels/remote_sync_view_model.dart b/lib/viewmodels/remote_sync_view_model.dart index d54f0ac..480ca38 100644 --- a/lib/viewmodels/remote_sync_view_model.dart +++ b/lib/viewmodels/remote_sync_view_model.dart @@ -1,13 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; import '../database/app_database.dart'; import '../models/remote_command.dart'; import '../services/remote_sync_service.dart'; -import '../utils/app_config.dart'; class RemoteSyncViewModel extends ChangeNotifier { final RemoteSyncService syncService; @@ -16,11 +13,10 @@ class RemoteSyncViewModel extends ChangeNotifier { _eventSubscription = syncService.events.listen(_onEvent); } - StreamSubscription? _eventSubscription; + late final StreamSubscription _eventSubscription; - List _recentEvents = []; - - List get recentEvents => _recentEvents; + bool _hasLoaded = false; + bool get hasLoaded => _hasLoaded; bool _isSyncing = false; bool get isSyncing => _isSyncing; @@ -40,114 +36,56 @@ class RemoteSyncViewModel extends ChangeNotifier { List _activity = []; List get activity => _activity; - bool _isRegistering = false; - bool get isRegistering => _isRegistering; - - String? _registrationError; - String? get registrationError => _registrationError; + Stream get tabsClosedRemotely => syncService.events.where( + (event) => event.type == RemoteSyncEventType.tabClosedRemotely, + ); Future init() async { - _isConfigured = await syncService.isConfigured; - _venueId = await syncService.venueId; + await _refreshStatus(); _lastSyncAt = await syncService.lastSuccessfulSyncAt; - await loadActivity(); - if (_isConfigured) { - syncService.startPeriodicSync(); - } + await _loadActivity(); + + if (_isConfigured) syncService.startPeriodicSync(); + + _hasLoaded = true; notifyListeners(); } - Future refreshStatus() async { + /// Called once registration stored new credentials. + Future activate() async { + await _refreshStatus(); + notifyListeners(); + + if (!_isConfigured) return; + + syncService.startPeriodicSync(); + await syncNow(); + } + + Future _refreshStatus() async { _isConfigured = await syncService.isConfigured; _venueId = await syncService.venueId; - notifyListeners(); } Future syncNow() async { _isSyncing = true; - _lastError = null; notifyListeners(); await syncService.sync(); - _isSyncing = syncService.isSyncing; - _lastSyncAt = await syncService.lastSuccessfulSyncAt; - await loadActivity(); + _isSyncing = false; + await _loadActivity(); notifyListeners(); } - Future loadActivity() async { + Future _loadActivity() async { try { _activity = await syncService.getRecentActivity(); - } catch (e) { + } on Exception catch (e) { debugPrint('RemoteSyncViewModel: loadActivity error: $e'); } } - Future registerDevice({ - required String venueId, - required String deviceName, - required String registrationCode, - }) async { - _isRegistering = true; - _registrationError = null; - notifyListeners(); - - try { - final response = await http.post( - Uri.parse('$kRemoteServerUrl/devices/register'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'venueId': venueId, - 'name': deviceName, - 'registrationCode': registrationCode, - }), - ).timeout(const Duration(seconds: 15)); - - if (response.statusCode == 201) { - final data = jsonDecode(response.body) as Map; - final deviceId = data['deviceId'] as String; - final token = data['token'] as String; - - await syncService.setCredentials( - deviceId: deviceId, - token: token, - venueId: venueId, - serverUrl: kRemoteServerUrl, - ); - - _isConfigured = true; - _venueId = venueId; - _isRegistering = false; - notifyListeners(); - - syncService.startPeriodicSync(); - await syncNow(); - - return true; - } else { - final body = response.body; - String message; - try { - final errorData = jsonDecode(body) as Map; - message = errorData['message'] as String? ?? 'Registration failed'; - } catch (_) { - message = 'Registration failed (HTTP ${response.statusCode})'; - } - _registrationError = message; - _isRegistering = false; - notifyListeners(); - return false; - } - } catch (e) { - debugPrint('RemoteSyncViewModel: registration error: $e'); - _registrationError = e.toString(); - _isRegistering = false; - notifyListeners(); - return false; - } - } - Future unlink() async { syncService.stopPeriodicSync(); await syncService.clearCredentials(); @@ -155,24 +93,23 @@ class RemoteSyncViewModel extends ChangeNotifier { _venueId = null; _lastSyncAt = null; _lastError = null; - _recentEvents = []; _activity = []; notifyListeners(); } - void startSync() { - syncService.startPeriodicSync(); - syncNow(); - } - void _onEvent(RemoteSyncEvent event) { - _recentEvents = [event, ..._recentEvents.take(49)]; - _lastSyncAt = DateTime.now(); - - if (event.type == RemoteSyncEventType.syncFailed) { - _lastError = event.message; - } else if (event.type == RemoteSyncEventType.syncCompleted) { - _lastError = null; + switch (event.type) { + case RemoteSyncEventType.syncCompleted: + _lastSyncAt = DateTime.now(); + _lastError = null; + case RemoteSyncEventType.syncFailed: + _lastError = event.message; + case RemoteSyncEventType.unauthorized: + // The router sends the device back to registration. + _isConfigured = false; + case RemoteSyncEventType.tabClosedRemotely: + case RemoteSyncEventType.conflict: + break; } notifyListeners(); @@ -184,13 +121,12 @@ class RemoteSyncViewModel extends ChangeNotifier { void resume() { syncService.resume(); - syncNow(); + unawaited(syncNow()); } @override void dispose() { - _eventSubscription?.cancel(); - syncService.dispose(); + _eventSubscription.cancel(); super.dispose(); } -} \ No newline at end of file +} diff --git a/lib/viewmodels/settings_view_model.dart b/lib/viewmodels/settings_view_model.dart index 6b3ecec..f1aa7ad 100644 --- a/lib/viewmodels/settings_view_model.dart +++ b/lib/viewmodels/settings_view_model.dart @@ -1,10 +1,11 @@ import 'package:flutter/foundation.dart'; -import 'package:kooltab2/utils/app_update_util.dart'; import 'package:ota_update/ota_update.dart'; +import '../models/app_error.dart'; import '../models/settings.dart'; import '../services/settings_service.dart'; import '../utils/app_config.dart'; +import '../utils/app_update_util.dart'; class SettingsViewModel extends ChangeNotifier { final SettingsService settingsService; @@ -14,7 +15,7 @@ class SettingsViewModel extends ChangeNotifier { AppSettings _settings = AppSettings.defaults; bool _isLoading = false; bool _hasLoaded = false; - String? _errorMessage; + AppError? _error; final AppUpdateUtil _updater = AppUpdateUtil(serverUrl: kUpdateServerUrl); @@ -28,7 +29,7 @@ class SettingsViewModel extends ChangeNotifier { bool get hasLoaded => _hasLoaded; - String? get errorMessage => _errorMessage; + AppError? get error => _error; Future ensureLoaded() async { if (_hasLoaded || _isLoading) return; @@ -39,14 +40,14 @@ class SettingsViewModel extends ChangeNotifier { if (_isLoading) return; _isLoading = true; - _errorMessage = null; + _error = null; notifyListeners(); try { _settings = await settingsService.getSettings(); } catch (e) { debugPrint('SettingsViewModel: load error: $e'); - _errorMessage = 'Could not load settings.'; + _error = AppError.loadSettings; } finally { _hasLoaded = true; _isLoading = false; @@ -72,33 +73,33 @@ class SettingsViewModel extends ChangeNotifier { UpdateInfo update, { void Function(double progress)? onProgress, }) async { - final url = update.download.startsWith("http") + final url = update.download.startsWith('http') ? update.download - : "$kUpdateServerUrl${update.download}"; + : '$kUpdateServerUrl${update.download}'; - debugPrint("Download Url: $url"); + debugPrint('Download Url: $url'); final stream = OtaUpdate().execute( url, - destinationFilename: "update.apk", + destinationFilename: 'update.apk', sha256checksum: update.sha256, ); await for (final event in stream) { - debugPrint("OTA: ${event.status} ${event.value}"); + debugPrint('OTA: ${event.status} ${event.value}'); if (event.status == OtaStatus.DOWNLOADING) { - final progress = double.tryParse(event.value ?? "0") ?? 0; + final progress = double.tryParse(event.value ?? '0') ?? 0; onProgress?.call(progress / 100); } if (event.status == OtaStatus.DOWNLOAD_ERROR) { - throw Exception("Download failed: ${event.value}"); + throw Exception('Download failed: ${event.value}'); } if (event.status == OtaStatus.INSTALLATION_ERROR) { - throw Exception("Installation failed: ${event.value}"); + throw Exception('Installation failed: ${event.value}'); } } } @@ -121,7 +122,7 @@ class SettingsViewModel extends ChangeNotifier { Future _save(AppSettings updated) async { final previous = _settings; _settings = updated; - _errorMessage = null; + _error = null; notifyListeners(); try { @@ -129,7 +130,7 @@ class SettingsViewModel extends ChangeNotifier { } catch (e) { debugPrint('SettingsViewModel: save error: $e'); _settings = previous; - _errorMessage = 'Could not save settings.'; + _error = AppError.saveSettings; notifyListeners(); } } diff --git a/lib/viewmodels/update_progress_view_model.dart b/lib/viewmodels/update_progress_view_model.dart index 979360c..e7a0a84 100644 --- a/lib/viewmodels/update_progress_view_model.dart +++ b/lib/viewmodels/update_progress_view_model.dart @@ -52,11 +52,7 @@ class UpdateProgressViewModel extends ChangeNotifier { destinationFilename: 'update.apk', sha256checksum: update.sha256, ) - .listen( - _onEvent, - onError: _onError, - onDone: _onDone, - ); + .listen(_onEvent, onError: _onError, onDone: _onDone); } void _runSimulation() { diff --git a/lib/views/admin_screen_view.dart b/lib/views/admin_screen_view.dart index a3ce553..c6646a8 100644 --- a/lib/views/admin_screen_view.dart +++ b/lib/views/admin_screen_view.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; +import '../models/remote_command.dart'; import '../utils/navigation.dart'; import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart'; @@ -41,7 +42,7 @@ class AdminScreenView extends StatelessWidget { ); if (confirmed == true && context.mounted) { - context.read().unlink(); + await context.read().unlink(); } } @@ -135,8 +136,13 @@ class AdminScreenView extends StatelessWidget { subtitle: remoteSync.isSyncing ? l10n.syncing : remoteSync.lastSyncAt != null - ? l10n.lastSync(remoteSync.lastSyncAt!.toLocal().toString().substring(0, 19)) - : l10n.notSyncedYet, + ? l10n.lastSync( + remoteSync.lastSyncAt! + .toLocal() + .toString() + .substring(0, 19), + ) + : l10n.notSyncedYet, trailing: remoteSync.isSyncing ? const SizedBox( width: 20, @@ -154,20 +160,18 @@ class AdminScreenView extends StatelessWidget { children: remoteSync.activity.map((entry) { return ListTile( dense: true, - leading: Icon( - entry.result == 'completed' - ? Icons.check_circle_outline - : entry.result == 'conflict' - ? Icons.warning_amber_outlined - : Icons.error_outline, - size: 20, - ), + leading: Icon(switch (entry.result) { + CommandResult.completed => Icons.check_circle_outline, + CommandResult.conflict => Icons.warning_amber_outlined, + CommandResult.failed || + CommandResult.expired => Icons.error_outline, + }, size: 20), title: Text( '${entry.commandType} (${entry.commandId.substring(0, 8)}...)', style: const TextStyle(fontSize: 13), ), subtitle: Text( - '${entry.processedAt.toLocal()} - ${entry.result}', + '${entry.processedAt.toLocal()} - ${entry.result.name}', style: const TextStyle(fontSize: 11), ), ); @@ -277,10 +281,7 @@ class _AdminTile extends StatelessWidget { ], ), ), - if (trailing != null) ...[ - const SizedBox(width: 8), - trailing!, - ], + if (trailing != null) ...[const SizedBox(width: 8), trailing!], if (onTap != null) Icon( Icons.chevron_right_rounded, @@ -292,4 +293,4 @@ class _AdminTile extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/views/bar/bar_screen_view.dart b/lib/views/bar/bar_screen_view.dart new file mode 100644 index 0000000..ba23729 --- /dev/null +++ b/lib/views/bar/bar_screen_view.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../l10n/app_localizations_helpers.dart'; +import '../../models/product.dart'; +import '../../models/remote_command.dart'; +import '../../services/product_service.dart'; +import '../../utils/app_updater.dart'; +import '../../viewmodels/admin_pin_view_model.dart'; +import '../../viewmodels/bar_screen_view_model.dart'; +import '../../viewmodels/pin_lock_view_model.dart'; +import '../../viewmodels/product_list_view_model.dart'; +import '../../viewmodels/remote_sync_view_model.dart'; +import '../../viewmodels/settings_view_model.dart'; +import '../dialogs/close_tab_dialog.dart'; +import '../dialogs/edit_tab_dialog.dart'; +import '../dialogs/new_tab_dialog.dart'; +import 'product_grid.dart'; +import 'tab_panel.dart'; + +class BarScreenView extends StatefulWidget { + const BarScreenView({super.key}); + + @override + State createState() => _BarScreenViewState(); +} + +class _BarScreenViewState extends State { + late final StreamSubscription _remoteCloseSubscription; + + @override + void initState() { + super.initState(); + + _remoteCloseSubscription = context + .read() + .tabsClosedRemotely + .listen(_showRemoteClose); + + WidgetsBinding.instance.addPostFrameCallback((_) { + UpdateChecker.check(context); + }); + } + + @override + void dispose() { + _remoteCloseSubscription.cancel(); + super.dispose(); + } + + void _showRemoteClose(RemoteSyncEvent event) { + if (!mounted) return; + + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.tabClosedRemotely( + event.tabCustomerName ?? '', + event.requestedBy ?? '', + ), + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + + Future _addProduct(Product product) async { + final l10n = AppLocalizations.of(context); + final viewModel = context.read(); + + if (viewModel.selectedTab == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.openOrSelectTab))); + return; + } + + try { + await viewModel.addProductToSelectedTab(product); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is InsufficientStockException + ? l10n.productOutOfStock + : l10n.couldNotAddProductToTab, + ), + ), + ); + } + } + + void _logout() { + context.read().lock(); + context.read().lock(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final viewModel = context.watch(); + final productsViewModel = context.watch(); + final settings = context.watch().settings; + final stockByProductId = { + for (final product in productsViewModel.products) + product.id: product.stockQuantity, + }; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.barTabs), + actions: [ + IconButton( + tooltip: l10n.admin, + onPressed: () => context.push('/admin'), + icon: const Icon(Icons.admin_panel_settings_outlined), + ), + if (kDebugMode) ...[ + const SizedBox(width: 6), + _DebugShortcut( + tooltip: l10n.manageProducts, + icon: Icons.inventory_2_outlined, + location: '/products', + ), + const SizedBox(width: 6), + _DebugShortcut( + tooltip: l10n.tabHistory, + icon: Icons.history_rounded, + location: '/history', + ), + ], + const SizedBox(width: 6), + IconButton( + tooltip: l10n.refresh, + onPressed: () => context.read().syncNow(), + icon: const Icon(Icons.refresh_rounded), + ), + const SizedBox(width: 6), + IconButton( + tooltip: l10n.settings, + onPressed: () => context.push('/settings'), + icon: const Icon(Icons.settings), + ), + if (context.watch().isPinSet) ...[ + const SizedBox(width: 6), + IconButton( + tooltip: l10n.logout, + onPressed: _logout, + icon: const Icon(Icons.logout), + ), + ], + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 8), + ), + resizeToAvoidBottomInset: false, + body: Builder( + builder: (context) { + if (viewModel.isLoading) { + return const Center( + child: CircularProgressIndicator(strokeWidth: 2.5), + ); + } + + if (viewModel.loadError != null) { + return _LoadError(message: l10n.errorText(viewModel.loadError)); + } + + return Row( + children: [ + Expanded( + flex: 2, + child: ProductGrid( + products: productsViewModel.products, + rows: settings.barGridRows, + hasSelectedTab: viewModel.selectedTab != null, + onProductTap: _addProduct, + ), + ), + Container(width: 1, color: Theme.of(context).dividerColor), + Expanded( + child: TabPanel( + tabs: viewModel.tabs, + selectedTab: viewModel.selectedTab, + selectedTabId: viewModel.selectedTabId, + stockByProductId: stockByProductId, + onNewTabPressed: () => showNewTabDialog(context), + onTabSelected: viewModel.selectTab, + onItemQuantityChanged: viewModel.changeItemQuantity, + onCloseTabPressed: () => confirmCloseTab(context), + onTabEdited: (tab) => showEditTabDialog(context, tab), + onTabDeleted: (tab) => confirmDeleteTab(context, tab), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _DebugShortcut extends StatelessWidget { + final String tooltip; + final IconData icon; + final String location; + + const _DebugShortcut({ + required this.tooltip, + required this.icon, + required this.location, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: tooltip, + style: IconButton.styleFrom( + backgroundColor: Colors.yellow, + foregroundColor: Colors.black, + ), + onPressed: () => context.push(location), + icon: Icon(icon), + ); + } +} + +class _LoadError extends StatelessWidget { + final String message; + + const _LoadError({required this.message}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline_rounded, + size: 40, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/bar/product_grid.dart b/lib/views/bar/product_grid.dart new file mode 100644 index 0000000..b0d9668 --- /dev/null +++ b/lib/views/bar/product_grid.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/product.dart'; +import '../widgets/product_tile.dart'; + +class ProductGrid extends StatelessWidget { + final List products; + final int rows; + final bool hasSelectedTab; + final ValueChanged onProductTap; + + const ProductGrid({ + super.key, + required this.products, + required this.rows, + required this.hasSelectedTab, + required this.onProductTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + + if (products.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.onSurface.withValues(alpha: 0.05), + ), + child: Icon( + Icons.inventory_2_outlined, + size: 40, + color: scheme.onSurface.withValues(alpha: 0.3), + ), + ), + const SizedBox(height: 16), + Text( + l10n.noProductsYet, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + l10n.addFirstProduct, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: () => context.push('/admin'), + icon: const Icon(Icons.admin_panel_settings_outlined), + label: Text(l10n.admin), + ), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 4), + child: Row( + children: [ + Text( + l10n.products, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(width: 10), + if (!hasSelectedTab) + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: scheme.onSurface.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.info_outline_rounded, + size: 14, + color: scheme.onSurface.withValues(alpha: 0.5), + ), + const SizedBox(width: 4), + Flexible( + child: Text( + l10n.selectTabToAddItems, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ), + ], + ), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + const horizontalPadding = 32.0; + const verticalPadding = 28.0; + const spacing = 12.0; + final rowCount = rows.clamp(2, 6).toInt(); + final rowHeight = + ((constraints.maxHeight - + verticalPadding - + (rowCount - 1) * spacing) / + rowCount) + .clamp(96.0, 320.0) + .toDouble(); + final columns = + (((constraints.maxWidth - horizontalPadding + spacing) / + (rowHeight + spacing)) + .ceil()) + .clamp(2, 8) + .toInt(); + + return GridView.builder( + key: ValueKey('product-grid-$rows'), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + itemCount: products.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1, + ), + itemBuilder: (context, index) { + final product = products[index]; + + return ProductTile( + product: product, + enabled: hasSelectedTab, + onTap: () => onProductTap(product), + ); + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/views/bar/selected_tab_details.dart b/lib/views/bar/selected_tab_details.dart new file mode 100644 index 0000000..cc751cb --- /dev/null +++ b/lib/views/bar/selected_tab_details.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/bar_tab.dart'; +import '../../models/tab_item.dart'; +import '../../services/product_service.dart'; +import '../../utils/money.dart'; + +class SelectedTabDetails extends StatelessWidget { + final BarTab tab; + final Map stockByProductId; + final Future Function(TabItem item, int delta) onItemQuantityChanged; + final VoidCallback onCloseTabPressed; + + const SelectedTabDetails({ + super.key, + required this.tab, + required this.stockByProductId, + required this.onItemQuantityChanged, + required this.onCloseTabPressed, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + tab.customerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Divider(), + ], + ), + const SizedBox(height: 16), + Expanded( + child: tab.items.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.local_bar_outlined, + size: 32, + color: scheme.onSurface.withValues(alpha: 0.25), + ), + const SizedBox(height: 8), + Text( + l10n.tapProductsToAdd, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ) + : ListView.separated( + itemCount: tab.items.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = tab.items[index]; + + return TabItemRow( + item: item, + stockByProductId: stockByProductId, + onQuantityChanged: onItemQuantityChanged, + ); + }, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.total, + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + tab.totalInCents.asEuro, + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(fontSize: 24), + ), + ], + ), + ), + FilledButton( + onPressed: tab.items.isEmpty ? null : onCloseTabPressed, + child: Text(l10n.closeTab), + ), + ], + ), + ), + ], + ); + } +} + +class TabItemRow extends StatelessWidget { + final TabItem item; + final Map stockByProductId; + final Future Function(TabItem item, int delta) onQuantityChanged; + + const TabItemRow({ + super.key, + required this.item, + required this.stockByProductId, + required this.onQuantityChanged, + }); + + Future _changeQuantity(BuildContext context, int delta) async { + final l10n = AppLocalizations.of(context); + + try { + await onQuantityChanged(item, delta); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is InsufficientStockException + ? l10n.productOutOfStock + : l10n.couldNotUpdateQuantity, + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final availableStock = stockByProductId[item.productId]; + final canIncrease = availableStock == null || availableStock > 0; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + '${item.quantity} × ${item.unitPriceInCents.asEuro}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + color: scheme.onSurface.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + visualDensity: VisualDensity.compact, + onPressed: () => _changeQuantity(context, -1), + icon: const Icon(Icons.remove_rounded, size: 18), + ), + SizedBox( + width: 22, + child: Text( + '${item.quantity}', + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: canIncrease + ? () => _changeQuantity(context, 1) + : null, + icon: const Icon(Icons.add_rounded, size: 18), + ), + ], + ), + ), + SizedBox( + width: 72, + child: Text( + item.lineTotalInCents.asEuro, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/bar/tab_panel.dart b/lib/views/bar/tab_panel.dart new file mode 100644 index 0000000..b910103 --- /dev/null +++ b/lib/views/bar/tab_panel.dart @@ -0,0 +1,277 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/bar_tab.dart'; +import '../../models/tab_item.dart'; +import '../../utils/money.dart'; +import 'selected_tab_details.dart'; + +class TabPanel extends StatefulWidget { + final List tabs; + final BarTab? selectedTab; + final String? selectedTabId; + final Map stockByProductId; + final VoidCallback onNewTabPressed; + final ValueChanged onTabSelected; + final Future Function(TabItem item, int delta) onItemQuantityChanged; + final VoidCallback onCloseTabPressed; + final Future Function(BarTab) onTabEdited; + final Future Function(BarTab) onTabDeleted; + + const TabPanel({ + super.key, + required this.tabs, + required this.selectedTab, + required this.selectedTabId, + required this.stockByProductId, + required this.onNewTabPressed, + required this.onTabSelected, + required this.onItemQuantityChanged, + required this.onCloseTabPressed, + required this.onTabEdited, + required this.onTabDeleted, + }); + + @override + State createState() => _TabPanelState(); +} + +class _TabPanelState extends State { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + + final filteredTabs = widget.tabs.where((tab) { + return tab.customerName.toLowerCase().contains( + _searchQuery.toLowerCase(), + ); + }).toList(); + + return DecoratedBox( + decoration: BoxDecoration(color: scheme.surfaceContainerLow), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + decoration: InputDecoration( + hintText: l10n.searchByName, + prefixIcon: Icon(Icons.search_rounded, size: 20), + suffixIcon: _searchQuery.isEmpty + ? null + : IconButton( + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + icon: const Icon(Icons.clear_rounded), + tooltip: l10n.clearSearch, + ), + isDense: true, + ), + ), + ), + + const SizedBox(width: 10), + + IconButton.filled( + onPressed: widget.onNewTabPressed, + icon: const Icon(Icons.add_rounded), + tooltip: l10n.openNewTab, + ), + ], + ), + + const SizedBox(height: 8), + + SizedBox( + height: 190, + child: OpenTabsList( + tabs: filteredTabs, + selectedTabId: widget.selectedTabId, + onTabSelected: widget.onTabSelected, + onTabEdited: widget.onTabEdited, + onTabDeleted: widget.onTabDeleted, + ), + ), + + const Divider(height: 28), + + Expanded( + child: widget.selectedTab == null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.receipt_long_outlined, + size: 36, + color: scheme.onSurface.withValues(alpha: 0.25), + ), + const SizedBox(height: 10), + Text( + l10n.selectOrOpenTab, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ) + : SelectedTabDetails( + tab: widget.selectedTab!, + stockByProductId: widget.stockByProductId, + onItemQuantityChanged: widget.onItemQuantityChanged, + onCloseTabPressed: widget.onCloseTabPressed, + ), + ), + ], + ), + ), + ); + } +} + +class OpenTabsList extends StatelessWidget { + final List tabs; + final String? selectedTabId; + final ValueChanged onTabSelected; + final Future Function(BarTab) onTabEdited; + final Future Function(BarTab) onTabDeleted; + + const OpenTabsList({ + super.key, + required this.tabs, + required this.selectedTabId, + required this.onTabSelected, + required this.onTabEdited, + required this.onTabDeleted, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + if (tabs.isEmpty) { + return Center( + child: Text( + l10n.noOpenTabs, + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + return ListView.separated( + itemCount: tabs.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final tab = tabs[index]; + final selected = tab.id == selectedTabId; + final scheme = Theme.of(context).colorScheme; + final primary = scheme.primary; + + return ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Slidable( + key: ValueKey(tab.id), + endActionPane: ActionPane( + motion: const DrawerMotion(), + extentRatio: 0.6, + children: [ + SlidableAction( + onPressed: (_) => onTabEdited(tab), + icon: Icons.edit_outlined, + label: l10n.edit, + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + ), + SlidableAction( + onPressed: (_) => onTabDeleted(tab), + icon: Icons.delete_outline_rounded, + label: l10n.delete, + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + ], + ), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: selected + ? primary.withValues(alpha: 0.14) + : scheme.onSurface.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected + ? primary.withValues(alpha: 0.6) + : scheme.onSurface.withValues(alpha: 0.06), + width: selected ? 1.4 : 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => onTabSelected(tab.id), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tab.customerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: scheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + l10n.tabItemSummary( + tab.itemCount, + tab.totalInCents.asEuro, + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart deleted file mode 100644 index 75f0c9d..0000000 --- a/lib/views/bar_screen_view.dart +++ /dev/null @@ -1,884 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:kooltab2/viewmodels/admin_pin_view_model.dart'; -import 'package:kooltab2/viewmodels/pin_lock_view_model.dart'; -import 'package:kooltab2/viewmodels/product_list_view_model.dart'; -import 'package:kooltab2/views/dialogs/close_tab_dialog.dart'; -import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart'; -import 'package:kooltab2/views/dialogs/new_tab_dialog.dart'; -import 'package:kooltab2/views/widgets/product_tile.dart'; -import 'package:provider/provider.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; - -import '../models/bar_tab.dart'; -import '../models/product.dart'; -import '../models/tab_item.dart'; -import '../models/remote_command.dart'; -import '../services/product_service.dart'; -import '../utils/app_updater.dart'; -import '../viewmodels/bar_screen_view_model.dart'; -import '../viewmodels/settings_view_model.dart'; -import '../l10n/app_localizations.dart'; -import '../l10n/app_localizations_helpers.dart'; - -class BarScreenView extends StatefulWidget { - const BarScreenView({super.key}); - - @override - State createState() => _BarScreenViewState(); -} - -class _BarScreenViewState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - UpdateChecker.check(context); - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _checkRemoteEvent(); - } - - @override - void didUpdateWidget(covariant BarScreenView oldWidget) { - super.didUpdateWidget(oldWidget); - _checkRemoteEvent(); - } - - void _checkRemoteEvent() { - final viewModel = context.read(); - final event = viewModel.lastRemoteEvent; - if (event == null) return; - - viewModel.clearRemoteEvent(); - - if (event.type == RemoteSyncEventType.tabClosedRemotely) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final l10n = AppLocalizations.of(context); - final message = l10n.tabClosedRemotely( - event.tabCustomerName ?? '', - event.requestedBy ?? '', - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - behavior: SnackBarBehavior.floating, - ), - ); - }); - } - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final viewModel = context.watch(); - final productsViewModel = context.watch(); - final settings = context.watch().settings; - final stockByProductId = { - for (final product in productsViewModel.products) - product.id: product.stockQuantity, - }; - - return Scaffold( - appBar: AppBar( - title: Text(l10n.barTabs), - actions: [ - IconButton( - tooltip: l10n.admin, - onPressed: () => context.push('/admin'), - icon: const Icon(Icons.admin_panel_settings_outlined), - ), - if (kDebugMode) ...[ - const SizedBox(width: 6), - IconButton( - tooltip: l10n.manageProducts, - style: IconButton.styleFrom( - backgroundColor: Colors.yellow, - foregroundColor: Colors.black, - ), - onPressed: () => context.push('/products'), - icon: const Icon(Icons.inventory_2_outlined), - ), - const SizedBox(width: 6), - IconButton( - tooltip: l10n.tabHistory, - style: IconButton.styleFrom( - backgroundColor: Colors.yellow, - foregroundColor: Colors.black, - ), - onPressed: () => context.push('/history'), - icon: const Icon(Icons.history_rounded), - ), - ], - const SizedBox(width: 6), - IconButton( - tooltip: l10n.refresh, - onPressed: () => viewModel.loadAndSync(), - icon: const Icon(Icons.refresh_rounded), - ), - const SizedBox(width: 6), - IconButton( - tooltip: l10n.settings, - onPressed: () => context.push('/settings'), - icon: const Icon(Icons.settings), - ), - if (context.watch().isPinSet) ...[ - const SizedBox(width: 6), - IconButton( - tooltip: l10n.logout, - onPressed: () { - Provider.of(context, listen: false).lock(); - Provider.of(context, listen: false).lock(); - }, - icon: const Icon(Icons.logout), - ), - ], - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 8), - ), - resizeToAvoidBottomInset: false, - body: Builder( - builder: (context) { - if (viewModel.isLoading) { - return const Center( - child: CircularProgressIndicator(strokeWidth: 2.5), - ); - } - - if (viewModel.errorMessage != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline_rounded, - size: 40, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(height: 12), - Text( - l10n.localizedError(viewModel.errorMessage), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), - ), - ); - } - - return Row( - children: [ - Expanded( - flex: 2, - child: _ProductGrid( - products: productsViewModel.products, - rows: settings.barGridRows, - hasSelectedTab: viewModel.selectedTab != null, - onProductTap: (product) async { - if (viewModel.selectedTab == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.openOrSelectTab)), - ); - return; - } - - try { - await viewModel.addProductToSelectedTab(product); - } catch (e, stack) { - if (e is! InsufficientStockException) { - Sentry.captureException(e, stackTrace: stack); - } - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e is InsufficientStockException - ? l10n.productOutOfStock - : l10n.couldNotAddProductToTab, - ), - ), - ); - } - }, - ), - ), - Container(width: 1, color: Theme.of(context).dividerColor), - Expanded( - flex: 1, - child: _TabPanel( - tabs: viewModel.tabs, - selectedTab: viewModel.selectedTab, - selectedTabId: viewModel.selectedTabId, - stockByProductId: stockByProductId, - onNewTabPressed: () => showNewTabDialog(context), - onTabSelected: viewModel.selectTab, - onItemQuantityChanged: viewModel.changeItemQuantity, - onCloseTabPressed: () => confirmCloseTab(context), - onTabEdited: (tab) => showEditTabDialog(context, tab), - onTabDeleted: (tab) => confirmDeleteTab(context, tab), - ), - ), - ], - ); - }, - ), - ); - } -} - -class _ProductGrid extends StatelessWidget { - final List products; - final int rows; - final bool hasSelectedTab; - final ValueChanged onProductTap; - - const _ProductGrid({ - required this.products, - required this.rows, - required this.hasSelectedTab, - required this.onProductTap, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); - - if (products.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: scheme.onSurface.withValues(alpha: 0.05), - ), - child: Icon( - Icons.inventory_2_outlined, - size: 40, - color: scheme.onSurface.withValues(alpha: 0.3), - ), - ), - const SizedBox(height: 16), - Text( - l10n.noProductsYet, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - l10n.addFirstProduct, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 20), - FilledButton.icon( - onPressed: () => context.push('/admin'), - icon: const Icon(Icons.admin_panel_settings_outlined), - label: Text(l10n.admin), - ), - ], - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 4), - child: Row( - children: [ - Text( - l10n.products, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(width: 10), - if (!hasSelectedTab) - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: scheme.onSurface.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.info_outline_rounded, - size: 14, - color: scheme.onSurface.withValues(alpha: 0.5), - ), - const SizedBox(width: 4), - Flexible( - child: Text( - l10n.selectTabToAddItems, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - ), - ), - ], - ), - ), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - const horizontalPadding = 32.0; - const verticalPadding = 28.0; - const spacing = 12.0; - final rowCount = rows.clamp(2, 6).toInt(); - final rowHeight = - ((constraints.maxHeight - - verticalPadding - - (rowCount - 1) * spacing) / - rowCount) - .clamp(96.0, 320.0) - .toDouble(); - final columns = - (((constraints.maxWidth - horizontalPadding + spacing) / - (rowHeight + spacing)) - .ceil()) - .clamp(2, 8) - .toInt(); - - return GridView.builder( - key: ValueKey('product-grid-$rows'), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - itemCount: products.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1, - ), - itemBuilder: (context, index) { - final product = products[index]; - - return ProductTile( - product: product, - enabled: hasSelectedTab, - onTap: () => onProductTap(product), - ); - }, - ); - }, - ), - ), - ], - ); - } -} - -class _TabPanel extends StatefulWidget { - final List tabs; - final BarTab? selectedTab; - final String? selectedTabId; - final Map stockByProductId; - final VoidCallback onNewTabPressed; - final ValueChanged onTabSelected; - final Future Function(TabItem item, int delta) onItemQuantityChanged; - final VoidCallback onCloseTabPressed; - final Future Function(BarTab) onTabEdited; - final Future Function(BarTab) onTabDeleted; - - const _TabPanel({ - required this.tabs, - required this.selectedTab, - required this.selectedTabId, - required this.stockByProductId, - required this.onNewTabPressed, - required this.onTabSelected, - required this.onItemQuantityChanged, - required this.onCloseTabPressed, - required this.onTabEdited, - required this.onTabDeleted, - }); - - @override - State<_TabPanel> createState() => _TabPanelState(); -} - -class _TabPanelState extends State<_TabPanel> { - final TextEditingController _searchController = TextEditingController(); - String _searchQuery = ''; - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); - - final filteredTabs = widget.tabs.where((tab) { - return tab.customerName.toLowerCase().contains( - _searchQuery.toLowerCase(), - ); - }).toList(); - - return DecoratedBox( - decoration: BoxDecoration(color: scheme.surfaceContainerLow), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - onChanged: (value) { - setState(() { - _searchQuery = value; - }); - }, - decoration: InputDecoration( - hintText: l10n.searchByName, - prefixIcon: Icon(Icons.search_rounded, size: 20), - suffixIcon: _searchQuery.isEmpty - ? null - : IconButton( - onPressed: () { - _searchController.clear(); - setState(() { - _searchQuery = ''; - }); - }, - icon: const Icon(Icons.clear_rounded), - tooltip: l10n.clearSearch, - ), - isDense: true, - ), - ), - ), - - const SizedBox(width: 10), - - IconButton.filled( - onPressed: widget.onNewTabPressed, - icon: const Icon(Icons.add_rounded), - tooltip: l10n.openNewTab, - ), - ], - ), - - const SizedBox(height: 8), - - SizedBox( - height: 190, - child: _OpenTabsList( - tabs: filteredTabs, - selectedTabId: widget.selectedTabId, - onTabSelected: widget.onTabSelected, - onTabEdited: widget.onTabEdited, - onTabDeleted: widget.onTabDeleted, - ), - ), - - const Divider(height: 28), - - Expanded( - child: widget.selectedTab == null - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.receipt_long_outlined, - size: 36, - color: scheme.onSurface.withValues(alpha: 0.25), - ), - const SizedBox(height: 10), - Text( - l10n.selectOrOpenTab, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ) - : _SelectedTabDetails( - tab: widget.selectedTab!, - stockByProductId: widget.stockByProductId, - onItemQuantityChanged: widget.onItemQuantityChanged, - onCloseTabPressed: widget.onCloseTabPressed, - ), - ), - ], - ), - ), - ); - } -} - -class _OpenTabsList extends StatelessWidget { - final List tabs; - final String? selectedTabId; - final ValueChanged onTabSelected; - final Future Function(BarTab) onTabEdited; - final Future Function(BarTab) onTabDeleted; - - const _OpenTabsList({ - required this.tabs, - required this.selectedTabId, - required this.onTabSelected, - required this.onTabEdited, - required this.onTabDeleted, - }); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - if (tabs.isEmpty) { - return Center( - child: Text( - l10n.noOpenTabs, - style: Theme.of(context).textTheme.bodyMedium, - ), - ); - } - - return ListView.separated( - itemCount: tabs.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final tab = tabs[index]; - final selected = tab.id == selectedTabId; - final scheme = Theme.of(context).colorScheme; - final primary = scheme.primary; - - return ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Slidable( - key: ValueKey(tab.id), - endActionPane: ActionPane( - motion: const DrawerMotion(), - extentRatio: 0.6, - children: [ - SlidableAction( - onPressed: (_) => onTabEdited(tab), - icon: Icons.edit_outlined, - label: l10n.edit, - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - ), - SlidableAction( - onPressed: (_) => onTabDeleted(tab), - icon: Icons.delete_outline_rounded, - label: l10n.delete, - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, - ), - ], - ), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - color: selected - ? primary.withValues(alpha: 0.14) - : scheme.onSurface.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: selected - ? primary.withValues(alpha: 0.6) - : scheme.onSurface.withValues(alpha: 0.06), - width: selected ? 1.4 : 1, - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: () => onTabSelected(tab.id), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - child: SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - tab.customerName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: scheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - l10n.tabItemSummary( - tab.itemCount, - tab.formattedTotal, - ), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - }, - ); - } -} - -class _SelectedTabDetails extends StatelessWidget { - final BarTab tab; - final Map stockByProductId; - final Future Function(TabItem item, int delta) onItemQuantityChanged; - final VoidCallback onCloseTabPressed; - - const _SelectedTabDetails({ - required this.tab, - required this.stockByProductId, - required this.onItemQuantityChanged, - required this.onCloseTabPressed, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - tab.customerName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - Divider(), - ], - ), - const SizedBox(height: 16), - Expanded( - child: tab.items.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.local_bar_outlined, - size: 32, - color: scheme.onSurface.withValues(alpha: 0.25), - ), - const SizedBox(height: 8), - Text( - l10n.tapProductsToAdd, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ) - : ListView.separated( - itemCount: tab.items.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final item = tab.items[index]; - - return _TabItemRow( - item: item, - stockByProductId: stockByProductId, - onQuantityChanged: onItemQuantityChanged, - ); - }, - ), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.total, - style: Theme.of(context).textTheme.bodySmall, - ), - Text( - tab.formattedTotal, - style: Theme.of( - context, - ).textTheme.headlineMedium?.copyWith(fontSize: 24), - ), - ], - ), - ), - FilledButton( - onPressed: tab.items.isEmpty ? null : onCloseTabPressed, - child: Text(l10n.closeTab), - ), - ], - ), - ), - ], - ); - } -} - -class _TabItemRow extends StatelessWidget { - final TabItem item; - final Map stockByProductId; - final Future Function(TabItem item, int delta) onQuantityChanged; - - const _TabItemRow({ - required this.item, - required this.stockByProductId, - required this.onQuantityChanged, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); - final availableStock = stockByProductId[item.productId]; - final canIncrease = availableStock == null || availableStock > 0; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.productName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - Text( - '${item.quantity} × ${item.formattedUnitPrice}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - Container( - decoration: BoxDecoration( - color: scheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - visualDensity: VisualDensity.compact, - onPressed: () async { - try { - await onQuantityChanged(item, -1); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.couldNotUpdateQuantity)), - ); - } - } - }, - icon: const Icon(Icons.remove_rounded, size: 18), - ), - SizedBox( - width: 22, - child: Text( - '${item.quantity}', - textAlign: TextAlign.center, - style: const TextStyle(fontWeight: FontWeight.w700), - ), - ), - IconButton( - visualDensity: VisualDensity.compact, - onPressed: canIncrease - ? () async { - try { - await onQuantityChanged(item, 1); - } catch (e, stack) { - if (e is! InsufficientStockException) { - Sentry.captureException(e, stackTrace: stack); - } - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e is InsufficientStockException - ? l10n.productOutOfStock - : l10n.couldNotUpdateQuantity, - ), - ), - ); - } - } - } - : null, - icon: const Icon(Icons.add_rounded, size: 18), - ), - ], - ), - ), - SizedBox( - width: 72, - child: Text( - item.formattedLineTotal, - textAlign: TextAlign.end, - style: const TextStyle(fontWeight: FontWeight.w700), - ), - ), - ], - ), - ); - } -} diff --git a/lib/views/dev_menu_view.dart b/lib/views/dev_menu_view.dart index fb15a57..84745bc 100644 --- a/lib/views/dev_menu_view.dart +++ b/lib/views/dev_menu_view.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import '../l10n/app_localizations.dart'; +import '../l10n/app_localizations_helpers.dart'; import '../utils/app_update_util.dart'; import '../utils/navigation.dart'; import '../viewmodels/dev_menu_view_model.dart'; -import '../l10n/app_localizations.dart'; -import '../l10n/app_localizations_helpers.dart'; class DevMenuView extends StatefulWidget { const DevMenuView({super.key}); @@ -68,7 +68,10 @@ class _DevMenuViewState extends State { body: ListView( padding: const EdgeInsets.symmetric(vertical: 12), children: [ - const Text("Hallo Matteo", style: TextStyle(color: Colors.pinkAccent),), + const Text( + 'Hallo Matteo', + style: TextStyle(color: Colors.pinkAccent), + ), _Section( title: l10n.dataManagement, children: [ diff --git a/lib/views/dialogs/close_tab_dialog.dart b/lib/views/dialogs/close_tab_dialog.dart index 5ff075e..af5a113 100644 --- a/lib/views/dialogs/close_tab_dialog.dart +++ b/lib/views/dialogs/close_tab_dialog.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:provider/provider.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; +import '../../l10n/app_localizations.dart'; import '../../models/payment_method.dart'; +import '../../utils/money.dart'; import '../../viewmodels/bar_screen_view_model.dart'; import '../widgets/slide_confirm.dart'; -import '../../l10n/app_localizations.dart'; Future confirmCloseTab(BuildContext context) async { final viewModel = context.read(); @@ -29,7 +29,7 @@ Future confirmCloseTab(BuildContext context) async { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(l10n.currentTotal(tab.formattedTotal)), + Text(l10n.currentTotal(tab.totalInCents.asEuro)), const SizedBox(height: 20), Text( l10n.paymentMethod, @@ -68,8 +68,7 @@ Future confirmCloseTab(BuildContext context) async { try { await viewModel.closeSelectedTab(paymentMethod: paymentMethod); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); + } catch (_) { if (!context.mounted) return; ScaffoldMessenger.of( context, diff --git a/lib/views/dialogs/edit_tab_dialog.dart b/lib/views/dialogs/edit_tab_dialog.dart index bd11f2f..122c6f3 100644 --- a/lib/views/dialogs/edit_tab_dialog.dart +++ b/lib/views/dialogs/edit_tab_dialog.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; @@ -18,8 +17,7 @@ Future showEditTabDialog(BuildContext context, BarTab tab) async { try { await context.read().renameTab(tab.id, name.trim()); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); + } catch (_) { if (!context.mounted) return; ScaffoldMessenger.of( context, @@ -107,8 +105,7 @@ Future confirmDeleteTab(BuildContext context, BarTab tab) async { try { await context.read().deleteTab(tab.id); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); + } catch (_) { if (!context.mounted) return; ScaffoldMessenger.of( context, diff --git a/lib/views/dialogs/new_tab_dialog.dart b/lib/views/dialogs/new_tab_dialog.dart index 86d8482..8e5eee4 100644 --- a/lib/views/dialogs/new_tab_dialog.dart +++ b/lib/views/dialogs/new_tab_dialog.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:kooltab2/viewmodels/bar_screen_view_model.dart'; import 'package:provider/provider.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; + import '../../l10n/app_localizations.dart'; +import '../../viewmodels/bar_screen_view_model.dart'; Future showNewTabDialog(BuildContext context) async { final l10n = AppLocalizations.of(context); @@ -44,8 +44,7 @@ Future showNewTabDialog(BuildContext context) async { try { await context.read().createTab(name.trim()); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); + } catch (_) { if (!context.mounted) return; ScaffoldMessenger.of( context, diff --git a/lib/views/history_screen_view.dart b/lib/views/history_screen_view.dart index 22c2934..28110b1 100644 --- a/lib/views/history_screen_view.dart +++ b/lib/views/history_screen_view.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:kooltab2/views/widgets/closed_tab_card.dart'; -import 'package:kooltab2/views/widgets/open_tab_history_card.dart'; import 'package:provider/provider.dart'; import '../app/router.dart'; +import '../l10n/app_localizations.dart'; +import '../l10n/app_localizations_helpers.dart'; import '../models/bar_tab.dart'; import '../models/closed_tab.dart'; import '../utils/navigation.dart'; import '../viewmodels/history_view_model.dart'; -import '../l10n/app_localizations.dart'; -import '../l10n/app_localizations_helpers.dart'; +import 'widgets/closed_tab_card.dart'; +import 'widgets/open_tab_history_card.dart'; class HistoryScreenView extends StatefulWidget { const HistoryScreenView({super.key}); @@ -87,7 +87,7 @@ class _HistoryScreenViewState extends State with RouteAware { ); } - if (viewModel.errorMessage != null) { + if (viewModel.error != null) { return Center( child: Padding( padding: const EdgeInsets.all(24), @@ -101,7 +101,7 @@ class _HistoryScreenViewState extends State with RouteAware { ), const SizedBox(height: 12), Text( - l10n.localizedError(viewModel.errorMessage), + l10n.errorText(viewModel.error), textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), diff --git a/lib/views/pin_lock_view.dart b/lib/views/pin_lock_view.dart index 1798139..f37bd25 100644 --- a/lib/views/pin_lock_view.dart +++ b/lib/views/pin_lock_view.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../viewmodels/pin_lock_view_model.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; +import '../models/app_error.dart'; +import '../viewmodels/pin_lock_view_model.dart'; enum PinEntryMode { unlock, create } @@ -18,7 +19,7 @@ class PinEntryView extends StatefulWidget { final bool isAdmin; final Future Function(String)? onVerify; final Future Function(String)? onSet; - final String? Function()? errorMessage; + final AppError? Function()? error; final bool showBack; const PinEntryView({ @@ -28,7 +29,7 @@ class PinEntryView extends StatefulWidget { this.isAdmin = false, this.onVerify, this.onSet, - this.errorMessage, + this.error, this.showBack = false, }); @@ -123,7 +124,7 @@ class _PinEntryViewState extends State { _firstEntry = null; _isConfirmStep = false; }); - _fail(l10n.localizedError(_errorMessage())); + _fail(l10n.errorText(_error())); } return; } @@ -136,7 +137,7 @@ class _PinEntryViewState extends State { if (ok) { widget.onSuccess?.call(); } else { - _fail(l10n.localizedError(_errorMessage())); + _fail(l10n.errorText(_error())); } } @@ -154,11 +155,11 @@ class _PinEntryViewState extends State { return context.read().verify(pin); } - String? _errorMessage() { - final callback = widget.errorMessage; + AppError? _error() { + final callback = widget.error; if (callback != null) return callback(); - return context.read().errorMessage; + return context.read().error; } void _fail(String message) { diff --git a/lib/views/product_form_view.dart b/lib/views/product_form_view.dart index e9a1baa..d2a3076 100644 --- a/lib/views/product_form_view.dart +++ b/lib/views/product_form_view.dart @@ -5,13 +5,12 @@ import 'package:image_picker/image_picker.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import '../models/product.dart'; -import '../viewmodels/product_list_view_model.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; +import '../models/product.dart'; import '../utils/navigation.dart'; +import '../viewmodels/product_list_view_model.dart'; class ProductFormView extends StatefulWidget { final String? productId; @@ -84,8 +83,7 @@ class _ProductFormViewState extends State { _hasImage = product.imagePath != null && product.imagePath!.isNotEmpty; setState(() => _isLoading = false); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); + } catch (_) { if (!mounted) return; ScaffoldMessenger.of( context, @@ -205,8 +203,7 @@ class _ProductFormViewState extends State { imagePath: _imagePath, ); } - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); + } catch (_) { if (!mounted) return; setState(() => _isSaving = false); ScaffoldMessenger.of( @@ -251,8 +248,7 @@ class _ProductFormViewState extends State { try { await viewModel.deleteProduct(widget.productId!); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); + } catch (_) { if (!mounted) return; ScaffoldMessenger.of( context, diff --git a/lib/views/product_list_view.dart b/lib/views/product_list_view.dart index 1a6b8e7..38c4ee7 100644 --- a/lib/views/product_list_view.dart +++ b/lib/views/product_list_view.dart @@ -4,10 +4,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -import '../viewmodels/product_list_view_model.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; +import '../utils/money.dart'; import '../utils/navigation.dart'; +import '../viewmodels/product_list_view_model.dart'; class ProductListView extends StatefulWidget { const ProductListView({super.key}); @@ -22,7 +23,7 @@ class _ProductListViewState extends State { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().loadProducts(); + context.read().ensureLoaded(); }); } @@ -51,10 +52,8 @@ class _ProductListViewState extends State { return const Center(child: CircularProgressIndicator()); } - if (viewModel.errorMessage != null) { - return Center( - child: Text(l10n.localizedError(viewModel.errorMessage)), - ); + if (viewModel.loadError != null) { + return Center(child: Text(l10n.errorText(viewModel.loadError))); } if (viewModel.products.isEmpty) { @@ -93,7 +92,7 @@ class _ProductListViewState extends State { subtitle: Text( l10n.productStockSummary( l10n.categoryLabel(product.category), - product.formattedPrice, + product.priceInCents.asEuro, product.stockQuantity, ), ), diff --git a/lib/views/registration_view.dart b/lib/views/registration_view.dart index 4f2e7da..ef2b731 100644 --- a/lib/views/registration_view.dart +++ b/lib/views/registration_view.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import '../l10n/app_localizations.dart'; import '../viewmodels/registration_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart'; -import '../l10n/app_localizations.dart'; class RegistrationView extends StatefulWidget { const RegistrationView({super.key}); @@ -115,8 +114,10 @@ class _RegistrationViewState extends State { vm.errorMessage ?? 'Unknown error', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), const SizedBox(height: 24), FilledButton.icon( @@ -150,21 +151,27 @@ class _RegistrationViewState extends State { 'Select your venue to link this device', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), const SizedBox(height: 32), Text( 'Select venue', style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - ), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), ), const SizedBox(height: 8), Container( decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.15), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.15), ), borderRadius: BorderRadius.circular(12), ), @@ -176,7 +183,10 @@ class _RegistrationViewState extends State { child: InkWell( onTap: () => vm.selectVenue(venue), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), child: Row( children: [ Icon( @@ -192,7 +202,9 @@ class _RegistrationViewState extends State { ), Icon( Icons.chevron_right_rounded, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.3), ), ], ), @@ -235,8 +247,10 @@ class _RegistrationViewState extends State { 'Enter device details to complete pairing', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), const SizedBox(height: 32), TextFormField( @@ -246,7 +260,9 @@ class _RegistrationViewState extends State { hintText: 'e.g. Bar terminal 1', border: OutlineInputBorder(), ), - validator: (v) => v == null || v.trim().isEmpty ? 'Device name is required' : null, + validator: (v) => v == null || v.trim().isEmpty + ? 'Device name is required' + : null, ), const SizedBox(height: 16), TextFormField( @@ -259,7 +275,9 @@ class _RegistrationViewState extends State { ), maxLength: 6, textCapitalization: TextCapitalization.characters, - validator: (v) => v == null || v.trim().length < 6 ? 'Enter the 6-character code' : null, + validator: (v) => v == null || v.trim().length < 6 + ? 'Enter the 6-character code' + : null, ), const SizedBox(height: 8), if (vm.errorMessage != null) @@ -289,7 +307,9 @@ class _RegistrationViewState extends State { if (!_formKey.currentState!.validate()) return; await vm.registerDevice( deviceName: _deviceNameController.text.trim(), - registrationCode: _regCodeController.text.trim().toUpperCase(), + registrationCode: _regCodeController.text + .trim() + .toUpperCase(), ); }, child: const Text('Register'), @@ -338,27 +358,29 @@ class _RegistrationViewState extends State { '${vm.selectedVenue?.name ?? "Venue"} is now linked.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), const SizedBox(height: 8), Text( 'Open tabs will sync to the dashboard.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), - ), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), ), const SizedBox(height: 32), FilledButton.icon( - onPressed: () { - context.read().startSync(); - context.go('/bar'); - }, + // Once configured, the router redirects to /bar by itself. + onPressed: () => context.read().activate(), icon: const Icon(Icons.arrow_forward), - label: const Text('Start using KoolTab'), + label: const Text('Start using KoolTap'), ), ], ); } -} \ No newline at end of file +} diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index 44bca58..80976cd 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -7,14 +7,14 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import '../models/settings.dart'; -import '../viewmodels/admin_pin_view_model.dart'; -import '../utils/app_update_util.dart'; -import '../utils/navigation.dart'; -import '../viewmodels/pin_lock_view_model.dart'; -import '../viewmodels/settings_view_model.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; +import '../models/settings.dart'; +import '../utils/app_update_util.dart'; +import '../utils/navigation.dart'; +import '../viewmodels/admin_pin_view_model.dart'; +import '../viewmodels/pin_lock_view_model.dart'; +import '../viewmodels/settings_view_model.dart'; class SettingsScreenView extends StatefulWidget { const SettingsScreenView({super.key}); @@ -41,7 +41,7 @@ class _SettingsScreenViewState extends State { Future currentVersion() async { final info = await PackageInfo.fromPlatform(); - debugPrint("Current app version: ${info.version}"); + debugPrint('Current app version: ${info.version}'); return info.version; } @@ -110,6 +110,30 @@ class _SettingsScreenViewState extends State { ); } + Future _pickOption( + BuildContext context, { + required List options, + required T selected, + required String Function(T) label, + }) { + return showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: RadioGroup( + groupValue: selected, + onChanged: (value) => Navigator.pop(sheetContext, value), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final option in options) + RadioListTile(value: option, title: Text(label(option))), + ], + ), + ), + ), + ); + } + void _showError(String message) { if (!mounted) return; ScaffoldMessenger.of( @@ -119,6 +143,8 @@ class _SettingsScreenViewState extends State { Future _enablePin() async { final l10n = AppLocalizations.of(context); + final pinLockViewModel = context.read(); + final settingsViewModel = context.read(); final pin = await _promptPin(l10n.setPinCode, hint: l10n.fourDigits); if (pin == null) return; @@ -127,26 +153,20 @@ class _SettingsScreenViewState extends State { return; } - final pinLockViewModel = context.read(); final success = await pinLockViewModel.setPin(pin); if (!success) { - _showError(l10n.localizedError(pinLockViewModel.errorMessage)); + _showError(l10n.errorText(pinLockViewModel.error)); return; } pinLockViewModel.setPinRequired(true); - - try { - await context.read().updatePinRequired(true); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); - _showError(l10n.couldNotSavePin); - } + await settingsViewModel.updatePinRequired(true); } Future _changePin() async { final l10n = AppLocalizations.of(context); + final pinLockViewModel = context.read(); final current = await _promptPin(l10n.enterCurrentPin); if (current == null) return; @@ -158,38 +178,32 @@ class _SettingsScreenViewState extends State { return; } - final pinLockViewModel = context.read(); final success = await pinLockViewModel.changePin( currentPin: current, newPin: newPin, ); if (!success) { - _showError(l10n.localizedError(pinLockViewModel.errorMessage)); + _showError(l10n.errorText(pinLockViewModel.error)); } } Future _disablePin() async { final l10n = AppLocalizations.of(context); + final pinLockViewModel = context.read(); + final settingsViewModel = context.read(); final current = await _promptPin(l10n.enterCurrentPin); if (current == null) return; - final pinLockViewModel = context.read(); final success = await pinLockViewModel.disablePin(current); if (!success) { - _showError(l10n.localizedError(pinLockViewModel.errorMessage)); + _showError(l10n.errorText(pinLockViewModel.error)); return; } pinLockViewModel.setPinRequired(false); - - try { - await context.read().updatePinRequired(false); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); - _showError(l10n.couldNotSavePin); - } + await settingsViewModel.updatePinRequired(false); } Future _configureAdminPin() async { @@ -207,7 +221,7 @@ class _SettingsScreenViewState extends State { final success = await adminPinViewModel.setPin(pin); if (!success) { - _showError(l10n.localizedError(adminPinViewModel.errorMessage)); + _showError(l10n.errorText(adminPinViewModel.error)); return; } @@ -235,7 +249,7 @@ class _SettingsScreenViewState extends State { ); if (!success) { - _showError(l10n.localizedError(adminPinViewModel.errorMessage)); + _showError(l10n.errorText(adminPinViewModel.error)); return; } @@ -330,26 +344,15 @@ class _SettingsScreenViewState extends State { AppLanguage.dutch => l10n.languageDutch, }, onTap: () async { - final selected = await showModalBottomSheet( - context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: AppLanguage.values.map((language) { - return RadioListTile( - value: language, - groupValue: settings.language, - title: Text(switch (language) { - AppLanguage.system => l10n.languageSystem, - AppLanguage.english => l10n.languageEnglish, - AppLanguage.dutch => l10n.languageDutch, - }), - onChanged: (value) => - Navigator.pop(context, value), - ); - }).toList(), - ), - ), + final selected = await _pickOption( + context, + options: AppLanguage.values, + selected: settings.language, + label: (language) => switch (language) { + AppLanguage.system => l10n.languageSystem, + AppLanguage.english => l10n.languageEnglish, + AppLanguage.dutch => l10n.languageDutch, + }, ); if (selected != null) { @@ -362,23 +365,11 @@ class _SettingsScreenViewState extends State { title: l10n.productGridRows, subtitle: l10n.rowsCount(settings.barGridRows), onTap: () async { - final selected = await showModalBottomSheet( - context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final rows in [2, 3, 4, 5, 6]) - RadioListTile( - value: rows, - groupValue: settings.barGridRows, - title: Text(l10n.rowsCount(rows)), - onChanged: (value) => - Navigator.pop(context, value), - ), - ], - ), - ), + final selected = await _pickOption( + context, + options: const [2, 3, 4, 5, 6], + selected: settings.barGridRows, + label: l10n.rowsCount, ); if (selected != null) { @@ -396,27 +387,16 @@ class _SettingsScreenViewState extends State { AppThemeMode.ugly => l10n.ugly, }, onTap: () async { - final selected = await showModalBottomSheet( - context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: AppThemeMode.values.map((mode) { - return RadioListTile( - value: mode, - groupValue: settings.themeMode, - title: Text(switch (mode) { - AppThemeMode.system => l10n.system, - AppThemeMode.light => l10n.light, - AppThemeMode.dark => l10n.dark, - AppThemeMode.ugly => l10n.ugly, - }), - onChanged: (value) => - Navigator.pop(context, value), - ); - }).toList(), - ), - ), + final selected = await _pickOption( + context, + options: AppThemeMode.values, + selected: settings.themeMode, + label: (mode) => switch (mode) { + AppThemeMode.system => l10n.system, + AppThemeMode.light => l10n.light, + AppThemeMode.dark => l10n.dark, + AppThemeMode.ugly => l10n.ugly, + }, ); if (selected != null) { @@ -484,7 +464,7 @@ class _SettingsScreenViewState extends State { _showUpdateDialog(update); } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); + unawaited(Sentry.captureException(e, stackTrace: stack)); if (!mounted) return; ScaffoldMessenger.of( diff --git a/lib/views/update_progress_view.dart b/lib/views/update_progress_view.dart index 48d9ddb..e913079 100644 --- a/lib/views/update_progress_view.dart +++ b/lib/views/update_progress_view.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; +import '../l10n/app_localizations_helpers.dart'; import '../utils/app_update_util.dart'; import '../utils/navigation.dart'; import '../viewmodels/update_progress_view_model.dart'; -import '../l10n/app_localizations.dart'; -import '../l10n/app_localizations_helpers.dart'; class UpdateProgressView extends StatefulWidget { final UpdateInfo update; @@ -166,7 +166,7 @@ class _UpdateProgressViewState extends State { ), ), const SizedBox(height: 24), - Text('KoolTab', style: Theme.of(context).textTheme.headlineMedium), + Text('KoolTap', style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 8), Text( l10n.version(widget.update.version), diff --git a/lib/views/widgets/closed_tab_card.dart b/lib/views/widgets/closed_tab_card.dart index b0b3335..39f9c78 100644 --- a/lib/views/widgets/closed_tab_card.dart +++ b/lib/views/widgets/closed_tab_card.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:kooltab2/models/closed_tab.dart'; -import 'package:kooltab2/models/closed_tab_item.dart'; -import 'package:kooltab2/models/payment_method.dart'; import '../../l10n/app_localizations.dart'; +import '../../models/closed_tab.dart'; +import '../../models/closed_tab_item.dart'; +import '../../models/payment_method.dart'; import '../../utils/history_grouping.dart'; +import '../../utils/money.dart'; class ClosedTabCard extends StatefulWidget { final ClosedTab closedTab; @@ -103,7 +104,8 @@ class _ClosedTabCardState extends State { switch (closedTab.paymentMethod) { PaymentMethod.cash => l10n.cash, PaymentMethod.payconiq => l10n.payconiq, - PaymentMethod.paymentPending => l10n.paymentPending, + PaymentMethod.paymentPending => + l10n.paymentPending, }, style: TextStyle( fontSize: 11, @@ -126,7 +128,7 @@ class _ClosedTabCardState extends State { ), const SizedBox(width: 8), Text( - closedTab.formattedTotal, + closedTab.totalInCents.asEuro, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w800, diff --git a/lib/views/widgets/open_tab_history_card.dart b/lib/views/widgets/open_tab_history_card.dart index c3fc47e..73ec789 100644 --- a/lib/views/widgets/open_tab_history_card.dart +++ b/lib/views/widgets/open_tab_history_card.dart @@ -4,6 +4,7 @@ import 'package:intl/intl.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; import '../../models/tab_item_purchase.dart'; +import '../../utils/money.dart'; class OpenTabHistoryCard extends StatefulWidget { final BarTab tab; @@ -133,7 +134,7 @@ class _OpenTabHistoryCardState extends State { ), const SizedBox(width: 8), Text( - tab.formattedTotal, + tab.totalInCents.asEuro, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w800, diff --git a/lib/views/widgets/product_tile.dart b/lib/views/widgets/product_tile.dart index 8622fd3..24a8ccf 100644 --- a/lib/views/widgets/product_tile.dart +++ b/lib/views/widgets/product_tile.dart @@ -1,9 +1,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:kooltab2/models/product.dart'; import '../../l10n/app_localizations.dart'; +import '../../models/product.dart'; class ProductTile extends StatelessWidget { final Product product; diff --git a/pubspec.lock b/pubspec.lock index da0753e..499475a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -154,7 +154,7 @@ packages: source: hosted version: "1.2.1" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -303,7 +303,7 @@ packages: source: sdk version: "0.0.0" flutter_launcher_icons: - dependency: "direct main" + dependency: "direct dev" description: name: flutter_launcher_icons sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" diff --git a/pubspec.yaml b/pubspec.yaml index bf08538..52baf87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,120 +1,54 @@ -name: kooltab2 -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +name: kooltap +description: "KoolTap: a tablet point-of-sale app for running bar tabs." +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.0.13 +version: 1.1.0 environment: sdk: ^3.12.2 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + collection: ^1.19.1 + crypto: ^3.0.0 cupertino_icons: ^1.0.8 - go_router: ^17.3.0 - provider: ^6.1.5+1 - uuid: ^4.5.3 drift: ^2.34.0 drift_flutter: ^0.3.0 - path_provider: ^2.1.6 - image_picker: ^1.2.3 - path: ^1.9.1 - flutter_slidable: ^4.0.3 - intl: ^0.20.2 flutter_secure_storage: 10.3.1 - crypto: ^3.0.0 + flutter_slidable: ^4.0.3 + flutter_svg: ^2.3.0 + go_router: ^17.3.0 http: ^1.6.0 - package_info_plus: ^10.1.0 + image_picker: ^1.2.3 + intl: ^0.20.2 open_filex: ^4.7.0 ota_update: ^7.1.0 - flutter_svg: ^2.3.0 + package_info_plus: ^10.1.0 + path: ^1.9.1 + path_provider: ^2.1.6 + provider: ^6.1.5+1 sentry_flutter: ^9.25.0 - flutter_launcher_icons: ^0.14.4 - + uuid: ^4.5.3 dev_dependencies: flutter_test: sdk: flutter - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 - drift_dev: ^2.34.2+1 build_runner: ^2.15.0 - mocktail: ^1.0.4 change_app_package_name: ^1.4.0 + drift_dev: ^2.34.2+1 + flutter_launcher_icons: ^0.14.4 + flutter_lints: ^6.0.0 + mocktail: ^1.0.4 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: generate: true - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - assets: - assets/icons/payconic.svg - assets/products/ - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/test/admin_pin_service_test.dart b/test/admin_pin_service_test.dart index 9a8c429..114c81d 100644 --- a/test/admin_pin_service_test.dart +++ b/test/admin_pin_service_test.dart @@ -1,52 +1,20 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/services/admin_pin_service.dart'; -import 'package:mocktail/mocktail.dart'; +import 'package:kooltap/services/admin_pin_service.dart'; -class MockFlutterSecureStorage extends Mock implements FlutterSecureStorage {} +import 'helpers/memory_secure_storage.dart'; void main() { - late AdminPinService service; - late MockFlutterSecureStorage mockStorage; - - setUp(() { - mockStorage = MockFlutterSecureStorage(); - service = AdminPinService(storage: mockStorage); - }); - test('uses separate secure-storage keys for the admin PIN', () async { - when( - () => mockStorage.write( - key: any(named: 'key'), - value: any(named: 'value'), - ), - ).thenAnswer((_) async {}); + final storage = MemorySecureStorage(); + final service = AdminPinService(storage: storage); await service.setPin('1234'); - verify( - () => mockStorage.write( - key: 'admin_pin_salt', - value: any(named: 'value'), - ), - ).called(1); - verify( - () => mockStorage.write( - key: 'admin_pin_hash', - value: any(named: 'value'), - ), - ).called(1); - verifyNever( - () => mockStorage.write( - key: 'pin_salt', - value: any(named: 'value'), - ), - ); - verifyNever( - () => mockStorage.write( - key: 'pin_hash', - value: any(named: 'value'), - ), + expect( + storage.values.keys, + containsAll(['admin_pin_salt', 'admin_pin_hash']), ); + expect(storage.values.keys, isNot(contains('pin_salt'))); + expect(storage.values.keys, isNot(contains('pin_hash'))); }); } diff --git a/test/app_lifecycle_lock_test.dart b/test/app_lifecycle_lock_test.dart index 365e3a1..bc244db 100644 --- a/test/app_lifecycle_lock_test.dart +++ b/test/app_lifecycle_lock_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/app/app_lifecycle_lock.dart'; +import 'package:kooltap/app/app_lifecycle_lock.dart'; void main() { test('locks when the app is no longer active', () { diff --git a/test/bar_screen_view_model_test.dart b/test/bar_screen_view_model_test.dart index 7a24a9d..dc3a389 100644 --- a/test/bar_screen_view_model_test.dart +++ b/test/bar_screen_view_model_test.dart @@ -1,39 +1,90 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/models/product.dart'; -import 'package:kooltab2/models/tab_item.dart'; -import 'package:kooltab2/services/bar_tab_service.dart'; -import 'package:kooltab2/services/product_service.dart'; -import 'package:kooltab2/services/remote_sync_service.dart'; -import 'package:kooltab2/viewmodels/bar_screen_view_model.dart'; -import 'package:kooltab2/viewmodels/inventory_view_model.dart'; -import 'package:kooltab2/viewmodels/remote_sync_view_model.dart'; +import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/models/tab_item.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/services/product_service.dart'; +import 'package:kooltap/viewmodels/bar_screen_view_model.dart'; import 'package:mocktail/mocktail.dart'; class MockBarTabService extends Mock implements BarTabService {} -class MockProductService extends Mock implements ProductService {} - -class MockRemoteSyncService extends Mock implements RemoteSyncService {} +BarTab _tab(String id) { + return BarTab( + id: id, + customerName: id, + openedAt: DateTime(2026), + items: const [], + ); +} void main() { late MockBarTabService barTabService; - late MockProductService productService; - late InventoryViewModel inventory; + late StreamController> openTabs; late BarScreenViewModel viewModel; - late RemoteSyncViewModel remoteSyncViewModel; setUp(() { barTabService = MockBarTabService(); - productService = MockProductService(); - inventory = InventoryViewModel(productService: productService); - remoteSyncViewModel = RemoteSyncViewModel( - syncService: MockRemoteSyncService(), - ); - viewModel = BarScreenViewModel( - barTabService: barTabService, - inventory: inventory, - remoteSyncViewModel: remoteSyncViewModel, - ); + openTabs = StreamController>.broadcast(); + when( + () => barTabService.watchOpenTabs(), + ).thenAnswer((_) => openTabs.stream); + + viewModel = BarScreenViewModel(barTabService: barTabService); + }); + + tearDown(() async { + viewModel.dispose(); + await openTabs.close(); + }); + + test('shows tabs from the stream and selects the first one', () async { + viewModel.ensureLoaded(); + expect(viewModel.isLoading, true); + + openTabs.add([_tab('a'), _tab('b')]); + await pumpEventQueue(); + + expect(viewModel.isLoading, false); + expect(viewModel.tabs.map((tab) => tab.id), ['a', 'b']); + expect(viewModel.selectedTabId, 'a'); + }); + + test('keeps the selection while that tab still exists', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a'), _tab('b')]); + await pumpEventQueue(); + viewModel.selectTab('b'); + + openTabs.add([_tab('c'), _tab('b')]); + await pumpEventQueue(); + + expect(viewModel.selectedTabId, 'b'); + }); + + test( + 'falls back to the first tab when the selected one disappears', + () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a'), _tab('b')]); + await pumpEventQueue(); + viewModel.selectTab('b'); + + openTabs.add([_tab('a')]); + await pumpEventQueue(); + + expect(viewModel.selectedTabId, 'a'); + }, + ); + + test('exposes a load error when the stream fails', () async { + viewModel.ensureLoaded(); + openTabs.addError(StateError('database closed')); + await pumpEventQueue(); + + expect(viewModel.loadError, isNotNull); + expect(viewModel.isLoading, false); }); test('propagates an insufficient-stock error from the transaction', () async { @@ -55,41 +106,29 @@ void main() { throwsA(isA()), ); - expect(viewModel.errorMessage, isNull); - verify( - () => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1), - ).called(1); + expect(viewModel.loadError, isNull); }); - test('updates inventory when a tab is deleted', () async { - final product = Product( - id: 'prod-1', - name: 'Chips', - category: 'Snacks', - stockQuantity: 8, - lowStockThreshold: 2, - priceInCents: 150, - ); - const item = TabItem( - id: 'item-1', - tabId: 'tab-1', - productId: 'prod-1', - productName: 'Chips', - quantity: 3, - unitPriceInCents: 150, - ); - - when(() => productService.getProducts()).thenAnswer((_) async => [product]); - await inventory.load(); + test('selects a newly created tab', () async { when( - () => barTabService.deleteTab('tab-1'), - ).thenAnswer((_) async => [item]); - when(() => barTabService.getOpenTabs()).thenAnswer((_) async => []); + () => barTabService.createTab(customerName: 'New'), + ).thenAnswer((_) async => _tab('new')); - await viewModel.deleteTab('tab-1'); + await viewModel.createTab('New'); - expect(inventory.products.first.stockQuantity, 11); - verify(() => barTabService.deleteTab('tab-1')).called(1); - verify(() => barTabService.getOpenTabs()).called(1); + expect(viewModel.selectedTabId, 'new'); }); + + test( + 'rethrows failed actions without turning them into a load error', + () async { + when( + () => barTabService.deleteTab('tab-1'), + ).thenAnswer((_) async => throw StateError('boom')); + + await expectLater(viewModel.deleteTab('tab-1'), throwsStateError); + + expect(viewModel.loadError, isNull); + }, + ); } diff --git a/test/bar_tab_model_test.dart b/test/bar_tab_model_test.dart index b49dfdb..75e11d3 100644 --- a/test/bar_tab_model_test.dart +++ b/test/bar_tab_model_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/models/bar_tab.dart'; -import 'package:kooltab2/models/tab_item.dart'; +import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/models/tab_item.dart'; void main() { group('BarTab', () { @@ -9,7 +9,6 @@ void main() { final tab = BarTab( id: 'tab-1', customerName: 'John', - status: 'open', openedAt: DateTime.now(), items: [ const TabItem( @@ -38,7 +37,6 @@ void main() { final tab = BarTab( id: 'tab-1', customerName: 'John', - status: 'open', openedAt: DateTime.now(), items: const [], ); @@ -52,7 +50,6 @@ void main() { final tab = BarTab( id: 'tab-1', customerName: 'John', - status: 'open', openedAt: DateTime.now(), items: [ const TabItem( @@ -81,7 +78,6 @@ void main() { final tab = BarTab( id: 'tab-1', customerName: 'John', - status: 'open', openedAt: DateTime.now(), items: const [], ); @@ -89,54 +85,5 @@ void main() { expect(tab.itemCount, 0); }); }); - - group('formattedTotal', () { - test('formats total with euro symbol', () { - final tab = BarTab( - id: 'tab-1', - customerName: 'John', - status: 'open', - openedAt: DateTime.now(), - items: const [ - TabItem( - id: 'item-1', - tabId: 'tab-1', - productId: 'prod-1', - productName: 'Beer', - quantity: 2, - unitPriceInCents: 425, - ), - ], - ); - - expect(tab.formattedTotal, '€8.50'); - }); - }); - - group('isOpen', () { - test('returns true for open status', () { - final tab = BarTab( - id: 'tab-1', - customerName: 'John', - status: 'open', - openedAt: DateTime.now(), - items: const [], - ); - - expect(tab.isOpen, true); - }); - - test('returns false for closed status', () { - final tab = BarTab( - id: 'tab-1', - customerName: 'John', - status: 'closed', - openedAt: DateTime.now(), - items: const [], - ); - - expect(tab.isOpen, false); - }); - }); }); } diff --git a/test/bar_tab_service_test.dart b/test/bar_tab_service_test.dart index faf5ae7..da6f798 100644 --- a/test/bar_tab_service_test.dart +++ b/test/bar_tab_service_test.dart @@ -1,10 +1,10 @@ import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/database/app_database.dart'; -import 'package:kooltab2/models/product.dart'; -import 'package:kooltab2/services/bar_tab_service.dart'; -import 'package:kooltab2/services/product_service.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/services/product_service.dart'; void main() { late AppDatabase database; @@ -49,14 +49,21 @@ void main() { group('DriftBarTabService', () { group('createTab', () { - test('creates tab with open status', () async { + test('creates an empty tab at version 1', () async { final tab = await service.createTab(customerName: 'John'); expect(tab.customerName, 'John'); - expect(tab.status, 'open'); + expect(tab.version, 1); expect(tab.items, isEmpty); }); + test('rejects a blank customer name', () async { + await expectLater( + service.createTab(customerName: ' '), + throwsA(isA()), + ); + }); + test('creates tab with unique id', () async { final tab1 = await service.createTab(customerName: 'John'); final tab2 = await service.createTab(customerName: 'Jane'); @@ -71,25 +78,6 @@ void main() { expect(tabs, isEmpty); }); - test('returns only open tabs', () async { - await service.createTab(customerName: 'Open Tab'); - await database - .into(database.barTabs) - .insert( - BarTabsCompanion.insert( - id: 'closed-tab', - customerName: 'Closed Tab', - status: const Value('closed'), - openedAt: DateTime.now(), - ), - ); - - final tabs = await service.getOpenTabs(); - - expect(tabs.length, 1); - expect(tabs.first.customerName, 'Open Tab'); - }); - test('tabs sorted by openedAt descending', () async { final now = DateTime.now(); await database @@ -98,7 +86,6 @@ void main() { BarTabsCompanion.insert( id: 'tab-first', customerName: 'First', - status: const Value('open'), openedAt: now.subtract(const Duration(hours: 1)), ), ); @@ -108,7 +95,6 @@ void main() { BarTabsCompanion.insert( id: 'tab-second', customerName: 'Second', - status: const Value('open'), openedAt: now, ), ); @@ -154,10 +140,8 @@ void main() { await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product); - final deletedItems = await service.deleteTab(tab.id); + await service.deleteTab(tab.id); - expect(deletedItems.length, 1); - expect(deletedItems.first.quantity, 2); expect(await service.getTabById(tab.id), isNull); final updatedProduct = await (database.select( @@ -165,8 +149,8 @@ void main() { )..where((row) => row.id.equals(product.id))).getSingle(); expect(updatedProduct.stockQuantity, 100); - final remainingItems = await database.select(database.tabItems).get(); - expect(remainingItems, isEmpty); + expect(await database.select(database.tabItems).get(), isEmpty); + expect(await database.select(database.tabItemPurchases).get(), isEmpty); }); }); @@ -412,16 +396,55 @@ void main() { expect(updatedTab!.items, isEmpty); }); - test('tab remains open but items cleared', () async { + test( + 'keeps the tab under the same name and drops its purchases', + () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + await service.addProductToTab(tabId: tab.id, product: product); + + await service.closeTab(tab.id); + + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.customerName, 'John'); + expect(updatedTab.items, isEmpty); + expect( + await database.select(database.tabItemPurchases).get(), + isEmpty, + ); + }, + ); + + test('throws when the tab changed since the expected version', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); - await service.closeTab(tab.id); + await expectLater( + service.closeTab(tab.id, expectedVersion: tab.version), + throwsA(isA()), + ); - final updatedTab = await service.getTabById(tab.id); - expect(updatedTab!.isOpen, true); - expect(updatedTab.items, isEmpty); + expect((await service.getTabById(tab.id))!.items, hasLength(1)); + expect(await service.getClosedTabs(), isEmpty); + }); + + test('closes when the expected version matches', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + await service.addProductToTab(tabId: tab.id, product: product); + final current = await service.getTabById(tab.id); + + await service.closeTab(tab.id, expectedVersion: current!.version); + + expect(await service.getClosedTabs(), hasLength(1)); + }); + + test('throws for an unknown tab', () async { + await expectLater( + service.closeTab('missing'), + throwsA(isA()), + ); }); test('closed tab preserves item prices', () async { @@ -436,6 +459,77 @@ void main() { }); }); + group('versioning', () { + test('every change bumps the tab version', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + + await service.addProductToTab(tabId: tab.id, product: product); + final afterAdd = (await service.getTabById(tab.id))!; + + await service.adjustTabItemQuantity( + tabItemId: afterAdd.items.first.id, + delta: 1, + ); + final afterAdjust = (await service.getTabById(tab.id))!; + + await service.renameTab(tabId: tab.id, customerName: 'Jane'); + final afterRename = (await service.getTabById(tab.id))!; + + expect(afterAdd.version, tab.version + 1); + expect(afterAdjust.version, afterAdd.version + 1); + expect(afterRename.version, afterAdjust.version + 1); + }); + + test('a failed add leaves the version untouched', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + await (database.update(database.products) + ..where((row) => row.id.equals(product.id))) + .write(const ProductsCompanion(stockQuantity: Value(0))); + + await expectLater( + service.addProductToTab(tabId: tab.id, product: product), + throwsA(isA()), + ); + + expect((await service.getTabById(tab.id))!.version, tab.version); + }); + }); + + group('watchOpenTabs', () { + test('emits again when an item is added', () async { + final tab = await service.createTab(customerName: 'John'); + final emissions = service.watchOpenTabs(); + + final expectation = expectLater( + emissions.map((tabs) => tabs.single.itemCount), + emitsThrough(1), + ); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + + await expectation; + }); + }); + + group('getDistinctCustomerNames', () { + test('returns each closed customer once, sorted', () async { + for (final name in ['Zoe', 'Adam', 'Zoe']) { + final tab = await service.createTab(customerName: name); + await service.addProductToTab( + tabId: tab.id, + product: createTestProduct(), + ); + await service.closeTab(tab.id); + } + + expect(await service.getDistinctCustomerNames(), ['Adam', 'Zoe']); + }); + }); + group('getClosedTabs', () { test('returns empty when no closed tabs', () async { final tabs = await service.getClosedTabs(); diff --git a/test/database_migration_test.dart b/test/database_migration_test.dart new file mode 100644 index 0000000..95a588a --- /dev/null +++ b/test/database_migration_test.dart @@ -0,0 +1,78 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/database/app_database.dart'; + +/// The parts of the v12 schema that the v13 migration touches. +const _v12Schema = [ + 'CREATE TABLE bar_tabs (id TEXT NOT NULL, customer_name TEXT NOT NULL, ' + "status TEXT NOT NULL DEFAULT 'open', opened_at INTEGER NOT NULL, " + 'closed_at INTEGER NULL, version INTEGER NOT NULL DEFAULT 0, ' + 'PRIMARY KEY (id))', + 'CREATE TABLE tab_items (id TEXT NOT NULL, tab_id TEXT NOT NULL, ' + 'product_id TEXT NOT NULL, product_name TEXT NOT NULL, ' + 'quantity INTEGER NOT NULL, unit_price_in_cents INTEGER NOT NULL, ' + 'created_at INTEGER NOT NULL, PRIMARY KEY (id))', + 'CREATE TABLE tab_item_purchases (id TEXT NOT NULL, ' + 'tab_item_id TEXT NOT NULL, purchased_at INTEGER NOT NULL, ' + 'removed_at INTEGER NULL, PRIMARY KEY (id))', + "INSERT INTO bar_tabs VALUES ('tab-1', 'Alice', 'open', 1700000000, NULL, 4)", + "INSERT INTO tab_items VALUES ('item-1', 'tab-1', 'prod-1', 'Beer', 3, 400, " + '1700000100)', + "INSERT INTO tab_item_purchases VALUES ('p-1', 'item-1', 1700000200, NULL)", + "INSERT INTO tab_item_purchases VALUES ('p-2', 'item-1', 1700000300, " + '1700000400)', + "INSERT INTO tab_item_purchases VALUES ('orphan', 'gone', 1700000500, NULL)", + 'PRAGMA user_version = 12', +]; + +void main() { + late AppDatabase database; + + setUp(() { + database = AppDatabase( + NativeDatabase.memory( + setup: (rawDatabase) { + for (final statement in _v12Schema) { + rawDatabase.execute(statement); + } + }, + ), + ); + }); + + tearDown(() async { + await database.close(); + }); + + test('drops the unused status columns and keeps the tab', () async { + final tabs = await database.select(database.barTabs).get(); + + expect(tabs.single.customerName, 'Alice'); + expect(tabs.single.version, 4); + + final columns = await database + .customSelect('PRAGMA table_info(bar_tabs)') + .map((row) => row.read('name')) + .get(); + expect(columns, isNot(contains('status'))); + expect(columns, isNot(contains('closed_at'))); + }); + + test('backfills missing purchases up to the item quantity', () async { + final purchases = await (database.select( + database.tabItemPurchases, + )..where((row) => row.tabItemId.equals('item-1'))).get(); + + final active = purchases.where((row) => row.removedAt == null); + expect(active, hasLength(3)); + expect(purchases.where((row) => row.removedAt != null), hasLength(1)); + }); + + test('deletes purchases whose item no longer exists', () async { + final orphan = await (database.select( + database.tabItemPurchases, + )..where((row) => row.id.equals('orphan'))).getSingleOrNull(); + + expect(orphan, isNull); + }); +} diff --git a/test/dev_menu_view_model_test.dart b/test/dev_menu_view_model_test.dart index f8aeee9..b295145 100644 --- a/test/dev_menu_view_model_test.dart +++ b/test/dev_menu_view_model_test.dart @@ -1,12 +1,12 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/database/app_database.dart'; -import 'package:kooltab2/models/product.dart'; -import 'package:kooltab2/services/bar_tab_service.dart'; -import 'package:kooltab2/services/pin_lock_service.dart'; -import 'package:kooltab2/services/product_service.dart'; -import 'package:kooltab2/services/settings_service.dart'; -import 'package:kooltab2/viewmodels/dev_menu_view_model.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/services/pin_lock_service.dart'; +import 'package:kooltap/services/product_service.dart'; +import 'package:kooltap/services/settings_service.dart'; +import 'package:kooltap/viewmodels/dev_menu_view_model.dart'; import 'package:mocktail/mocktail.dart'; class MockBarTabService extends Mock implements BarTabService {} diff --git a/test/edit_tab_dialog_test.dart b/test/edit_tab_dialog_test.dart index f8d6275..76bcab7 100644 --- a/test/edit_tab_dialog_test.dart +++ b/test/edit_tab_dialog_test.dart @@ -1,41 +1,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/l10n/app_localizations.dart'; -import 'package:kooltab2/models/bar_tab.dart'; -import 'package:kooltab2/services/bar_tab_service.dart'; -import 'package:kooltab2/services/product_service.dart'; -import 'package:kooltab2/services/remote_sync_service.dart'; -import 'package:kooltab2/viewmodels/bar_screen_view_model.dart'; -import 'package:kooltab2/viewmodels/inventory_view_model.dart'; -import 'package:kooltab2/viewmodels/remote_sync_view_model.dart'; -import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart'; +import 'package:kooltap/l10n/app_localizations.dart'; +import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/viewmodels/bar_screen_view_model.dart'; +import 'package:kooltap/views/dialogs/edit_tab_dialog.dart'; import 'package:mocktail/mocktail.dart'; import 'package:provider/provider.dart'; class MockBarTabService extends Mock implements BarTabService {} -class MockProductService extends Mock implements ProductService {} - -class MockRemoteSyncService extends Mock implements RemoteSyncService {} - void main() { testWidgets('edit dialog disposes its controller after closing', ( tester, ) async { final barTabService = MockBarTabService(); - final productService = MockProductService(); - final remoteSyncViewModel = RemoteSyncViewModel( - syncService: MockRemoteSyncService(), - ); - final viewModel = BarScreenViewModel( - barTabService: barTabService, - inventory: InventoryViewModel(productService: productService), - remoteSyncViewModel: remoteSyncViewModel, - ); + final viewModel = BarScreenViewModel(barTabService: barTabService); final tab = BarTab( id: 'tab-1', customerName: 'Before', - status: 'open', openedAt: DateTime(2026), items: const [], ); @@ -43,7 +26,6 @@ void main() { when( () => barTabService.renameTab(tabId: 'tab-1', customerName: 'After'), ).thenAnswer((_) async {}); - when(() => barTabService.getOpenTabs()).thenAnswer((_) async => [tab]); await tester.pumpWidget( ChangeNotifierProvider.value( diff --git a/test/helpers/memory_secure_storage.dart b/test/helpers/memory_secure_storage.dart new file mode 100644 index 0000000..a90a61b --- /dev/null +++ b/test/helpers/memory_secure_storage.dart @@ -0,0 +1,49 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// An in-memory stand-in for the platform keystore. +class MemorySecureStorage extends Fake implements FlutterSecureStorage { + final Map values = {}; + + @override + Future read({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async => values[key]; + + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (value == null) { + values.remove(key); + } else { + values[key] = value; + } + } + + @override + Future delete({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + values.remove(key); + } +} diff --git a/test/history_grouping_test.dart b/test/history_grouping_test.dart index ec4ff8a..de51ea0 100644 --- a/test/history_grouping_test.dart +++ b/test/history_grouping_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/models/closed_tab_item.dart'; -import 'package:kooltab2/utils/history_grouping.dart'; +import 'package:kooltap/models/closed_tab_item.dart'; +import 'package:kooltap/utils/history_grouping.dart'; void main() { test('groups items by purchase day with newest day first', () { diff --git a/test/history_view_model_test.dart b/test/history_view_model_test.dart index 1f72dc9..3acfe2f 100644 --- a/test/history_view_model_test.dart +++ b/test/history_view_model_test.dart @@ -1,9 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/models/bar_tab.dart'; -import 'package:kooltab2/models/closed_tab.dart'; -import 'package:kooltab2/models/payment_method.dart'; -import 'package:kooltab2/services/bar_tab_service.dart'; -import 'package:kooltab2/viewmodels/history_view_model.dart'; +import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/models/closed_tab.dart'; +import 'package:kooltap/models/payment_method.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/viewmodels/history_view_model.dart'; import 'package:mocktail/mocktail.dart'; class MockBarTabService extends Mock implements BarTabService {} @@ -90,19 +90,12 @@ ClosedTab _closedTab(String id, DateTime closedAt) { } BarTab _barTab(String id, DateTime openedAt) { - return BarTab( - id: id, - customerName: id, - status: 'open', - openedAt: openedAt, - items: const [], - ); + return BarTab(id: id, customerName: id, openedAt: openedAt, items: const []); } final _openTab = BarTab( id: 'tab-1', customerName: 'Alice', - status: 'open', openedAt: DateTime(2026, 8, 2, 12), items: const [], ); diff --git a/test/inventory_view_model_test.dart b/test/inventory_view_model_test.dart index ef6773a..e6bf497 100644 --- a/test/inventory_view_model_test.dart +++ b/test/inventory_view_model_test.dart @@ -1,157 +1,89 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/services/product_service.dart'; +import 'package:kooltap/viewmodels/inventory_view_model.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:kooltab2/models/product.dart'; -import 'package:kooltab2/services/product_service.dart'; -import 'package:kooltab2/viewmodels/inventory_view_model.dart'; class MockProductService extends Mock implements ProductService {} void main() { - late InventoryViewModel viewModel; late MockProductService mockService; + late StreamController> products; + late InventoryViewModel viewModel; + + const beer = Product( + id: 'prod-1', + name: 'Beer', + category: 'Drinks', + stockQuantity: 20, + lowStockThreshold: 5, + priceInCents: 400, + ); setUp(() { mockService = MockProductService(); + products = StreamController>.broadcast(); + when(() => mockService.watchProducts()).thenAnswer((_) => products.stream); + viewModel = InventoryViewModel(productService: mockService); }); - setUpAll(() { - registerFallbackValue(const Product( - id: 'fallback', - name: 'Fallback', - category: 'Test', - stockQuantity: 0, - lowStockThreshold: 0, - priceInCents: 0, - )); + tearDown(() async { + viewModel.dispose(); + await products.close(); }); - final testProducts = [ - const Product( - id: 'prod-1', - name: 'Beer', - category: 'Drinks', - stockQuantity: 20, - lowStockThreshold: 5, - priceInCents: 400, - ), - const Product( - id: 'prod-2', - name: 'Wine', - category: 'Drinks', - stockQuantity: 3, - lowStockThreshold: 5, - priceInCents: 600, - ), - ]; - group('InventoryViewModel', () { - group('load', () { - test('loads products from service', () async { - when(() => mockService.getProducts()) - .thenAnswer((_) async => testProducts); + test('is loading until the first emission', () async { + viewModel.ensureLoaded(); + expect(viewModel.isLoading, true); - await viewModel.load(); + products.add([beer]); + await pumpEventQueue(); - expect(viewModel.products, testProducts); - verify(() => mockService.getProducts()).called(1); - }); - - test('notifies listeners after load', () async { - var notifyCount = 0; - viewModel.addListener(() => notifyCount++); - when(() => mockService.getProducts()) - .thenAnswer((_) async => testProducts); - - await viewModel.load(); - - expect(notifyCount, 1); - }); + expect(viewModel.isLoading, false); + expect(viewModel.products, [beer]); }); - group('decreaseStock', () { - test('decreases stock via service', () async { - when(() => mockService.decreaseStock('prod-1', 3)) - .thenAnswer((_) async {}); + test('follows stock changes from the database', () async { + viewModel.ensureLoaded(); + products.add([beer]); + await pumpEventQueue(); - await viewModel.decreaseStock('prod-1', 3); + products.add([beer.copyWith(stockQuantity: 19)]); + await pumpEventQueue(); - verify(() => mockService.decreaseStock('prod-1', 3)).called(1); - }); - - test('updates local product cache', () async { - when(() => mockService.getProducts()) - .thenAnswer((_) async => testProducts); - when(() => mockService.decreaseStock('prod-1', 5)) - .thenAnswer((_) async {}); - - await viewModel.load(); - await viewModel.decreaseStock('prod-1', 5); - - final product = viewModel.products.firstWhere((p) => p.id == 'prod-1'); - expect(product.stockQuantity, 15); - }); - - test('notifies listeners after decrease', () async { - var notifyCount = 0; - viewModel.addListener(() => notifyCount++); - when(() => mockService.decreaseStock('prod-1', 1)) - .thenAnswer((_) async {}); - - await viewModel.decreaseStock('prod-1', 1); - - expect(notifyCount, 1); - }); + expect(viewModel.products.single.stockQuantity, 19); }); - group('increaseStock', () { - test('increases stock via service', () async { - when(() => mockService.increaseStock('prod-1', 5)) - .thenAnswer((_) async {}); + test('subscribes only once', () async { + viewModel.ensureLoaded(); + viewModel.ensureLoaded(); - await viewModel.increaseStock('prod-1', 5); - - verify(() => mockService.increaseStock('prod-1', 5)).called(1); - }); - - test('updates local product cache', () async { - when(() => mockService.getProducts()) - .thenAnswer((_) async => testProducts); - when(() => mockService.increaseStock('prod-2', 2)) - .thenAnswer((_) async {}); - - await viewModel.load(); - await viewModel.increaseStock('prod-2', 2); - - final product = viewModel.products.firstWhere((p) => p.id == 'prod-2'); - expect(product.stockQuantity, 5); - }); - - test('notifies listeners after increase', () async { - var notifyCount = 0; - viewModel.addListener(() => notifyCount++); - when(() => mockService.increaseStock('prod-1', 1)) - .thenAnswer((_) async {}); - - await viewModel.increaseStock('prod-1', 1); - - expect(notifyCount, 1); - }); + verify(() => mockService.watchProducts()).called(1); }); - group('edge cases', () { - test('handles decrease for product not in local cache', () async { - when(() => mockService.getProducts()) - .thenAnswer((_) async => testProducts); - when(() => mockService.decreaseStock('unknown', 1)) - .thenAnswer((_) async {}); + test('notifies listeners on every emission', () async { + var notifyCount = 0; + viewModel.addListener(() => notifyCount++); + viewModel.ensureLoaded(); - await viewModel.load(); - await viewModel.decreaseStock('unknown', 1); + products + ..add([beer]) + ..add([]); + await pumpEventQueue(); - verify(() => mockService.decreaseStock('unknown', 1)).called(1); - expect(viewModel.products.length, 2); - }); + expect(notifyCount, 2); + }); + + test('exposes a load error when the stream fails', () async { + viewModel.ensureLoaded(); + products.addError(StateError('database closed')); + await pumpEventQueue(); + + expect(viewModel.loadError, isNotNull); }); }); } diff --git a/test/kooltap_api_client_test.dart b/test/kooltap_api_client_test.dart new file mode 100644 index 0000000..7dca68c --- /dev/null +++ b/test/kooltap_api_client_test.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:kooltap/models/remote_command.dart'; +import 'package:kooltap/services/kooltap_api_client.dart'; + +KoolTapApiClient _clientReturning(http.Response response) { + return KoolTapApiClient( + client: MockClient((_) async => response), + baseUrl: 'https://api.test', + ); +} + +http.Response _error(int status, String code, String message) { + return http.Response( + jsonEncode({ + 'error': {'code': code, 'message': message}, + }), + status, + ); +} + +void main() { + group('registerDevice', () { + test('surfaces the server reason for a rejected code', () async { + final client = _clientReturning( + _error( + 401, + 'invalid_registration_code', + 'Invalid or expired registration code', + ), + ); + + await expectLater( + client.registerDevice( + venueId: 'venue', + deviceName: 'Bar', + registrationCode: 'WRONG', + ), + throwsA( + isA() + .having((e) => e, 'type', isNot(isA())) + .having((e) => e.code, 'code', 'invalid_registration_code') + .having( + (e) => e.message, + 'message', + 'Invalid or expired registration code', + ), + ), + ); + }); + + test('falls back to the reason phrase for a non-JSON error', () async { + final client = _clientReturning( + http.Response('', 502, reasonPhrase: 'Bad Gateway'), + ); + + await expectLater( + client.registerDevice( + venueId: 'venue', + deviceName: 'Bar', + registrationCode: 'CODE', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Bad Gateway', + ), + ), + ); + }); + }); + + group('sync', () { + test('treats a 401 as a revoked device token', () async { + final client = _clientReturning( + _error(401, 'invalid_token', 'Token revoked'), + ); + + await expectLater( + client.sync( + token: 'token', + request: const SyncRequest(deviceId: 'device', openTabs: []), + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/money_test.dart b/test/money_test.dart new file mode 100644 index 0000000..ba45d9a --- /dev/null +++ b/test/money_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/utils/money.dart'; + +void main() { + group('Cents.asEuro', () { + test('formats cents in Belgian notation', () { + expect(1234.asEuro, '€ 12,34'); + }); + + test('formats zero', () { + expect(0.asEuro, '€ 0,00'); + }); + + test('formats whole euros with two decimals', () { + expect(500.asEuro, '€ 5,00'); + }); + + test('groups thousands', () { + expect(123456.asEuro, '€ 1.234,56'); + }); + }); +} diff --git a/test/pin_lock_service_test.dart b/test/pin_lock_service_test.dart index d891264..1f0e8f3 100644 --- a/test/pin_lock_service_test.dart +++ b/test/pin_lock_service_test.dart @@ -1,149 +1,167 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:kooltab2/services/pin_lock_service.dart'; +import 'package:kooltap/services/pin_lock_service.dart'; -class MockFlutterSecureStorage extends Mock - implements FlutterSecureStorage {} +import 'helpers/memory_secure_storage.dart'; -String _computeHash(String pin, String salt) { - final bytes = utf8.encode('$salt:$pin'); - return sha256.convert(bytes).toString(); +String _legacyHash(String pin, String salt) { + return sha256.convert(utf8.encode('$salt:$pin')).toString(); } void main() { + late MemorySecureStorage storage; + late DateTime now; late PinLockService service; - late MockFlutterSecureStorage mockStorage; setUp(() { - mockStorage = MockFlutterSecureStorage(); - service = PinLockService(storage: mockStorage); + storage = MemorySecureStorage(); + now = DateTime(2026, 10, 7, 12); + service = PinLockService(storage: storage, clock: () => now); }); + Future failTimes(int count) async { + for (var attempt = 0; attempt < count; attempt++) { + await service.verifyPin('0000'); + } + } + group('PinLockService', () { group('hasPin', () { test('returns false when no hash stored', () async { - when(() => mockStorage.read(key: 'pin_hash')) - .thenAnswer((_) async => null); - - final result = await service.hasPin(); - - expect(result, false); + expect(await service.hasPin(), false); }); test('returns false when hash is empty', () async { - when(() => mockStorage.read(key: 'pin_hash')) - .thenAnswer((_) async => ''); + storage.values['pin_hash'] = ''; - final result = await service.hasPin(); - - expect(result, false); + expect(await service.hasPin(), false); }); - test('returns true when hash exists', () async { - when(() => mockStorage.read(key: 'pin_hash')) - .thenAnswer((_) async => 'somehash'); + test('returns true once a pin is set', () async { + await service.setPin('1234'); - final result = await service.hasPin(); - - expect(result, true); + expect(await service.hasPin(), true); }); }); group('setPin', () { - test('stores salt and hash', () async { - when(() => mockStorage.write(key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - + test('stores a salted PBKDF2 hash', () async { await service.setPin('1234'); - verify(() => mockStorage.write(key: 'pin_salt', value: any(named: 'value'))).called(1); - verify(() => mockStorage.write(key: 'pin_hash', value: any(named: 'value'))).called(1); + expect(storage.values['pin_salt'], isNotEmpty); + expect(storage.values['pin_hash'], startsWith(r'pbkdf2-sha256$')); + expect(storage.values['pin_hash'], isNot(contains('1234'))); }); test('generates unique salts for different pins', () async { - when(() => mockStorage.write(key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - await service.setPin('1234'); - final firstSaltCapture = verify(() => mockStorage.write( - key: 'pin_salt', - value: captureAny(named: 'value'), - )).captured.first; + final firstSalt = storage.values['pin_salt']; await service.setPin('5678'); - final secondSaltCapture = verify(() => mockStorage.write( - key: 'pin_salt', - value: captureAny(named: 'value'), - )).captured.last; - expect(firstSaltCapture, isNot(equals(secondSaltCapture))); + expect(storage.values['pin_salt'], isNot(firstSalt)); }); }); group('verifyPin', () { test('returns false when salt is missing', () async { - when(() => mockStorage.read(key: 'pin_salt')) - .thenAnswer((_) async => null); - when(() => mockStorage.read(key: 'pin_hash')) - .thenAnswer((_) async => 'somehash'); + storage.values['pin_hash'] = 'somehash'; - final result = await service.verifyPin('1234'); - - expect(result, false); + expect(await service.verifyPin('1234'), false); }); test('returns false when hash is missing', () async { - when(() => mockStorage.read(key: 'pin_salt')) - .thenAnswer((_) async => 'somesalt'); - when(() => mockStorage.read(key: 'pin_hash')) - .thenAnswer((_) async => null); + storage.values['pin_salt'] = 'somesalt'; - final result = await service.verifyPin('1234'); - - expect(result, false); + expect(await service.verifyPin('1234'), false); }); test('returns true for correct pin', () async { - const salt = 'test_salt'; - const pin = '1234'; - final expectedHash = _computeHash(pin, salt); - when(() => mockStorage.read(key: 'pin_salt')) - .thenAnswer((_) async => salt); - when(() => mockStorage.read(key: 'pin_hash')) - .thenAnswer((_) async => expectedHash); + await service.setPin('1234'); - final result = await service.verifyPin('1234'); - - expect(result, true); + expect(await service.verifyPin('1234'), true); }); test('returns false for incorrect pin', () async { - const salt = 'test_salt'; - final correctHash = _computeHash('1234', salt); - when(() => mockStorage.read(key: 'pin_salt')) - .thenAnswer((_) async => salt); - when(() => mockStorage.read(key: 'pin_hash')) - .thenAnswer((_) async => correctHash); + await service.setPin('1234'); - final result = await service.verifyPin('5678'); + expect(await service.verifyPin('5678'), false); + }); - expect(result, false); + test('accepts a legacy hash and upgrades it', () async { + storage.values['pin_salt'] = 'test_salt'; + storage.values['pin_hash'] = _legacyHash('1234', 'test_salt'); + + expect(await service.verifyPin('1234'), true); + expect(storage.values['pin_hash'], startsWith(r'pbkdf2-sha256$')); + expect(await service.verifyPin('1234'), true); + }); + + test('rejects a wrong pin against a legacy hash', () async { + storage.values['pin_salt'] = 'test_salt'; + storage.values['pin_hash'] = _legacyHash('1234', 'test_salt'); + + expect(await service.verifyPin('5678'), false); + expect(storage.values['pin_hash'], _legacyHash('1234', 'test_salt')); + }); + }); + + group('lockout', () { + test('locks after five wrong attempts', () async { + await service.setPin('1234'); + await failTimes(5); + + await expectLater( + service.verifyPin('1234'), + throwsA(isA()), + ); + }); + + test('unlocks once the lockout has passed', () async { + await service.setPin('1234'); + await failTimes(5); + + now = now.add(const Duration(seconds: 31)); + + expect(await service.verifyPin('1234'), true); + }); + + test('doubles the lockout for every further failure', () async { + await service.setPin('1234'); + await failTimes(5); + now = now.add(const Duration(seconds: 31)); + await failTimes(1); + + now = now.add(const Duration(seconds: 31)); + await expectLater( + service.verifyPin('1234'), + throwsA(isA()), + ); + + now = now.add(const Duration(seconds: 30)); + expect(await service.verifyPin('1234'), true); + }); + + test('a correct pin resets the failure count', () async { + await service.setPin('1234'); + await failTimes(4); + await service.verifyPin('1234'); + await failTimes(4); + + expect(await service.verifyPin('1234'), true); }); }); group('clearPin', () { - test('deletes both salt and hash', () async { - when(() => mockStorage.delete(key: any(named: 'key'))) - .thenAnswer((_) async {}); + test('deletes the pin and any lockout', () async { + await service.setPin('1234'); + await failTimes(5); await service.clearPin(); - verify(() => mockStorage.delete(key: 'pin_salt')).called(1); - verify(() => mockStorage.delete(key: 'pin_hash')).called(1); + expect(storage.values, isEmpty); }); }); }); diff --git a/test/product_model_test.dart b/test/product_model_test.dart index 4031afc..4b18b55 100644 --- a/test/product_model_test.dart +++ b/test/product_model_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/models/product.dart'; +import 'package:kooltap/models/product.dart'; void main() { group('Product', () { @@ -44,47 +44,6 @@ void main() { }); }); - group('formattedPrice', () { - test('formats price with euro symbol and 2 decimals', () { - const product = Product( - id: '1', - name: 'Test', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 5, - priceInCents: 1234, - ); - - expect(product.formattedPrice, '€12.34'); - }); - - test('formats zero price correctly', () { - const product = Product( - id: '1', - name: 'Test', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 5, - priceInCents: 0, - ); - - expect(product.formattedPrice, '€0.00'); - }); - - test('formats whole euros correctly', () { - const product = Product( - id: '1', - name: 'Test', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 5, - priceInCents: 500, - ); - - expect(product.formattedPrice, '€5.00'); - }); - }); - group('copyWith', () { test('creates copy with updated fields', () { const original = Product( diff --git a/test/product_navigation_test.dart b/test/product_navigation_test.dart index d0a6a61..00198ea 100644 --- a/test/product_navigation_test.dart +++ b/test/product_navigation_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; -import 'package:kooltab2/l10n/app_localizations.dart'; -import 'package:kooltab2/models/product.dart'; -import 'package:kooltab2/views/widgets/product_tile.dart'; +import 'package:kooltap/l10n/app_localizations.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/views/widgets/product_tile.dart'; void main() { testWidgets('long-pressing a product tile returns to the bar after edit', ( diff --git a/test/product_service_test.dart b/test/product_service_test.dart index 392b729..b590711 100644 --- a/test/product_service_test.dart +++ b/test/product_service_test.dart @@ -1,9 +1,9 @@ import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/database/app_database.dart'; -import 'package:kooltab2/models/product.dart'; -import 'package:kooltab2/services/product_service.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/services/product_service.dart'; void main() { late AppDatabase database; diff --git a/test/remote_sync_service_test.dart b/test/remote_sync_service_test.dart new file mode 100644 index 0000000..674cbc5 --- /dev/null +++ b/test/remote_sync_service_test.dart @@ -0,0 +1,403 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/models/remote_command.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/services/kooltap_api_client.dart'; +import 'package:kooltap/services/remote_sync_service.dart'; + +import 'helpers/memory_secure_storage.dart'; + +const _baseUrl = 'https://api.test'; + +const _beer = Product( + id: 'prod-1', + name: 'Beer', + category: 'Drinks', + stockQuantity: 100, + lowStockThreshold: 10, + priceInCents: 400, +); + +/// A scripted server: [syncResponse] answers `/sync`, acks are recorded. +class _FakeServer { + http.Response syncResponse = http.Response('{}', 200); + final List> syncRequests = []; + final List> acks = []; + + /// While set, `/sync` responses wait for it to complete. + Completer? holdSync; + + late final client = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + + if (request.url.path.endsWith('/sync')) { + syncRequests.add(body); + await holdSync?.future; + return syncResponse; + } + if (request.url.path.endsWith('/ack')) { + acks.add(body); + return http.Response('', 200); + } + return http.Response('not found', 404); + }); + + void respondWith(Map json) { + syncResponse = http.Response(jsonEncode(json), 200); + } +} + +Map _closeCommand({ + required String id, + required String tabId, + required int expectedVersion, + String type = 'close_tab', + DateTime? createdAt, +}) { + return { + 'id': id, + 'type': type, + 'tabId': tabId, + 'expectedVersion': expectedVersion, + 'paymentMethod': 'payconiq', + 'requestedBy': 'Dashboard', + 'createdAt': (createdAt ?? DateTime.now()).toIso8601String(), + }; +} + +void main() { + late AppDatabase database; + late DriftBarTabService barTabService; + late MemorySecureStorage storage; + late _FakeServer server; + late RemoteSyncService service; + late List events; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + barTabService = DriftBarTabService(database: database); + storage = MemorySecureStorage(); + server = _FakeServer(); + + service = RemoteSyncService( + database: database, + barTabService: barTabService, + apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl), + secureStorage: storage, + ); + await service.setCredentials( + deviceId: 'device-1', + token: 'token', + venueId: 'venue-1', + ); + + events = []; + service.events.listen(events.add); + + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: _beer.id, + name: _beer.name, + category: _beer.category, + stockQuantity: _beer.stockQuantity, + lowStockThreshold: _beer.lowStockThreshold, + priceInCents: _beer.priceInCents, + ), + ); + }); + + tearDown(() async { + service.dispose(); + await database.close(); + }); + + List eventTypes() => [ + for (final event in events) event.type, + ]; + + group('sync', () { + test('sends a snapshot of the open tabs and stores the cursor', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.respondWith({'cursor': 'cursor-1'}); + + await service.sync(); + await pumpEventQueue(); + + final snapshot = server.syncRequests.single['openTabs'] as List; + expect(snapshot.single['customerName'], 'Alice'); + expect(snapshot.single['totalInCents'], 400); + expect(eventTypes(), [RemoteSyncEventType.syncCompleted]); + expect(await service.lastSuccessfulSyncAt, isNotNull); + + await service.sync(); + expect(server.syncRequests.last['cursor'], 'cursor-1'); + }); + + test('reports a server error as a failure, not a success', () async { + server.syncResponse = http.Response('oops', 500); + + await service.sync(); + await pumpEventQueue(); + + expect(eventTypes(), [RemoteSyncEventType.syncFailed]); + expect(await service.lastSuccessfulSyncAt, isNull); + + final state = await database.select(database.remoteSyncState).getSingle(); + expect(state.consecutiveFailureCount, 1); + }); + + test('reports rate limiting as a failure', () async { + server.syncResponse = http.Response( + '', + 429, + headers: {'retry-after': '120'}, + ); + + await service.sync(); + await pumpEventQueue(); + + expect(eventTypes(), [RemoteSyncEventType.syncFailed]); + }); + + test('drops the token and reports when it was revoked', () async { + server.syncResponse = http.Response('', 401); + + await service.sync(); + await pumpEventQueue(); + + expect(eventTypes(), [RemoteSyncEventType.unauthorized]); + expect(await service.isConfigured, false); + }); + + test('creates planned tabs that are not open yet, once', () async { + await barTabService.createTab(customerName: 'Alice'); + server.respondWith({ + 'plannedTabNames': ['Alice', 'Bob', 'Bob', ' '], + }); + + await service.sync(); + + final names = (await barTabService.getOpenTabs()).map( + (tab) => tab.customerName, + ); + expect(names, unorderedEquals(['Alice', 'Bob'])); + }); + }); + + group('close_tab commands', () { + test('closes the tab when the version matches', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + final current = (await barTabService.getTabById(tab.id))!; + server.respondWith({ + 'commands': [ + _closeCommand( + id: 'cmd-1', + tabId: tab.id, + expectedVersion: current.version, + ), + ], + }); + + await service.sync(); + await pumpEventQueue(); + + final closed = await barTabService.getClosedTabs(); + expect(closed.single.customerName, 'Alice'); + expect(closed.single.paymentMethod.value, 'payconiq'); + expect(server.acks.single['status'], 'completed'); + expect(eventTypes(), contains(RemoteSyncEventType.tabClosedRemotely)); + }); + + test('refuses to close a tab that changed since the request', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.respondWith({ + 'commands': [ + _closeCommand(id: 'cmd-1', tabId: tab.id, expectedVersion: 1), + ], + }); + + await service.sync(); + await pumpEventQueue(); + + expect(await barTabService.getClosedTabs(), isEmpty); + expect((await barTabService.getTabById(tab.id))!.items, hasLength(1)); + expect(server.acks.single['status'], 'conflict'); + expect(eventTypes(), contains(RemoteSyncEventType.conflict)); + }); + + test('reports a conflict for an unknown tab', () async { + server.respondWith({ + 'commands': [ + _closeCommand(id: 'cmd-1', tabId: 'missing', expectedVersion: 1), + ], + }); + + await service.sync(); + + expect(server.acks.single['status'], 'conflict'); + }); + + test('expires commands older than the expiry window', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + server.respondWith({ + 'commands': [ + _closeCommand( + id: 'cmd-1', + tabId: tab.id, + expectedVersion: tab.version, + createdAt: DateTime.now().subtract(const Duration(days: 2)), + ), + ], + }); + + await service.sync(); + + expect(server.acks.single['status'], 'expired'); + }); + + test('fails commands of an unknown type', () async { + server.respondWith({ + 'commands': [ + _closeCommand( + id: 'cmd-1', + tabId: 'tab', + expectedVersion: 1, + type: 'refund', + ), + ], + }); + + await service.sync(); + + expect(server.acks.single['status'], 'failed'); + }); + + test( + 're-acks an already processed command without re-running it', + () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + final current = (await barTabService.getTabById(tab.id))!; + server.respondWith({ + 'commands': [ + _closeCommand( + id: 'cmd-1', + tabId: tab.id, + expectedVersion: current.version, + ), + ], + }); + + await service.sync(); + await service.sync(); + + expect(await barTabService.getClosedTabs(), hasLength(1)); + expect(server.acks.map((ack) => ack['status']), [ + 'completed', + 'completed', + ]); + + final activity = await service.getRecentActivity(); + expect(activity.single.result, CommandResult.completed); + }, + ); + }); + + group('sync after tab changes', () { + late RemoteSyncService fast; + + setUp(() { + fast = RemoteSyncService( + database: database, + barTabService: barTabService, + apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl), + secureStorage: storage, + changeDebounce: const Duration(milliseconds: 50), + changeMaxDelay: const Duration(milliseconds: 300), + ); + server.respondWith({'cursor': 'cursor-1'}); + }); + + tearDown(() => fast.dispose()); + + Future wait(int ms) => + Future.delayed(Duration(milliseconds: ms)); + + int syncedTotal() => + (server.syncRequests.last['openTabs'] as List).single['totalInCents'] + as int; + + test('syncs shortly after a tab changes', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + fast.startPeriodicSync(); + await wait(150); + expect(server.syncRequests, isEmpty, reason: 'starting is not a change'); + + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await wait(250); + + expect(server.syncRequests, hasLength(1)); + expect(syncedTotal(), 400); + }); + + test('bundles a burst of changes into one sync', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + fast.startPeriodicSync(); + await wait(150); + + for (var i = 0; i < 5; i++) { + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + } + await wait(250); + + expect(server.syncRequests, hasLength(1)); + expect(syncedTotal(), 2000); + }); + + test('does not sync after changes once stopped', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + fast.startPeriodicSync(); + await wait(150); + fast.stopPeriodicSync(); + + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await wait(250); + + expect(server.syncRequests, isEmpty); + }); + + test( + 'a sync requested during a running sync runs once afterwards', + () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + server.holdSync = Completer(); + + final first = fast.sync(); + await wait(50); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + unawaited(fast.sync()); + unawaited(fast.sync()); + + server.holdSync!.complete(); + server.holdSync = null; + await first; + await wait(150); + + expect(server.syncRequests, hasLength(2)); + expect(syncedTotal(), 400, reason: 'the follow-up carries the change'); + }, + ); + }); +} diff --git a/test/settings_service_test.dart b/test/settings_service_test.dart index a5eaa06..c1e8ef8 100644 --- a/test/settings_service_test.dart +++ b/test/settings_service_test.dart @@ -1,8 +1,8 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/database/app_database.dart'; -import 'package:kooltab2/models/settings.dart'; -import 'package:kooltab2/services/settings_service.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/settings.dart'; +import 'package:kooltap/services/settings_service.dart'; void main() { late AppDatabase database; diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 6560267..7f3bc6a 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,10 +1,10 @@ # Project-level configuration. cmake_minimum_required(VERSION 3.14) -project(kooltab2 LANGUAGES CXX) +project(kooltap LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "kooltab2") +set(BINARY_NAME "kooltap") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index ca74bcc..9e2c8d7 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "dev.brammie15" "\0" - VALUE "FileDescription", "kooltab2" "\0" + VALUE "FileDescription", "KoolTap" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "kooltab2" "\0" + VALUE "InternalName", "kooltap" "\0" VALUE "LegalCopyright", "Copyright (C) 2026 dev.brammie15. All rights reserved." "\0" - VALUE "OriginalFilename", "kooltab2.exe" "\0" - VALUE "ProductName", "kooltab2" "\0" + VALUE "OriginalFilename", "kooltap.exe" "\0" + VALUE "ProductName", "KoolTap" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index d0cff4f..07a8f3d 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"kooltab2", origin, size)) { + if (!window.Create(L"KoolTap", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true);