Skip to content

Impulse Mobile App — Specification

Version: 0.3 Scope: Flutter mobile companion app for the Impulse Watch and Anchor devices. Covers current architecture and all implemented features. Intended as a complete reference for a coding agent implementing or modifying the app.


Table of Contents

  1. Overview & Architecture
  2. Models
  3. Services
  4. Screens
  5. Widgets
  6. Utilities
  7. Theme
  8. BLE Constants & Wire Formats
  9. Local Storage
  10. Device Settings & Anchor Servo Control

1. Overview & Architecture

The Impulse app is a Flutter mobile control center for managing watch enforcement automations and BLE-connected anchor devices.

Core responsibilities: 1. Scan for and manage BLE devices (watches and anchors). 2. Create, edit, and delete time-based automations with location/WiFi triggers. 3. Push schedules and settings to the watch over BLE. 4. Monitor live watch status (state, battery, worn, WiFi). 5. Identify and control anchors over BLE. 6. Provide a real-time debug packet inspector.

Key dependencies (pubspec.yaml): - flutter_blue_plus 1.32.12 — BLE scanning and GATT - shared_preferences 2.3.4 — Key-value persistence - permission_handler 11.3.1 — Bluetooth and location permissions

Navigation: 4-tab BottomNavigationBar (Devices, Automations, Settings, Debug).

1.1 Architectural Patterns

Services layer: Three singleton services own all state and side effects. UI reads from them and calls their methods.

Service Responsibility
BluetoothService Device history, BLE scanning, anchor IP management
WatchService Active GATT connection to one watch, characteristic I/O
AutomationService CRUD for automations, schedule encoding
DebugLogService Broadcast stream of BLE packet log entries

UI layer: StatefulWidget screens. State updates via setState(). No Provider/Riverpod/Bloc. Services are accessed as singletons directly inside widgets.

Streams: WatchService exposes two broadcast StreamController streams (statusStream, seenAnchorsStream) so multiple screens can subscribe without interference.

Persistence: All persistent state lives in SharedPreferences (JSON-encoded). No SQLite.


2. Models

2.1 BluetoothDeviceModel (lib/models/bluetooth_device_model.dart)

Represents a known BLE device (watch or anchor).

enum DeviceType { watch, anchor, unknown }

class BluetoothDeviceModel {
  final String id;              // Anchor: UUID from iBeacon data. Watch: platform remote ID.
  final String name;            // Display name. Editable by the user (app-only).
  final bool isConnected;       // Current connection status.
  final int rssi;               // Signal strength in dBm.
  final DateTime lastSeen;      // Timestamp of last BLE advertisement.
  final DeviceType deviceType;  // Inferred from advertisement data.
  final String? ipAddress;      // IPv4 address (anchors only).
  final DateTime? ipLastUpdated;
  final String? bleRemoteId;    // Platform remote ID if device appeared in a direct scan.
                                // Null for anchors known only via watch's SeenAnchors.
}

id semantics: - For anchors: the 16-byte UUID extracted from iBeacon manufacturer data (formatted as UUID string). - For watches: the platform-assigned BLE remote ID (MAC on Android, UUID on iOS).

bleRemoteId: Only set when the device appeared in a direct BLE scan by the app. Anchors known only from the watch's SeenAnchors characteristic will have bleRemoteId = null and cannot be connected to directly until scanned.

name: Initially populated from the BLE advertisement name. Can be overwritten by the user through the Device Settings modal (Section 10). Changes are persisted to SharedPreferences immediately.

Persistence: Serialized to JSON, stored in SharedPreferences under "paired_devices".


2.2 Automation (lib/models/automation_model.dart)

Represents a single scheduled enforcement event.

enum RecurrenceType { once, daily, weekly, monthly }
enum Criteria { getAway, stayNear, getOffWifi, getOnWifi }
enum EnforcementProfile {
  strictSilent, normalSilent, looseSilent,
  strictBoth, normalBoth, looseBoth,
  strictBuzz, normalBuzz, looseBuzz
}
enum AnchorEnforcementProfile { light, medium, hard }

class Automation {
  final String id;                           // UUID v4
  final DateTime referenceDate;              // Anchor/origin date (UTC)
  final TimeOfDay startTime;
  final TimeOfDay endTime;
  final RecurrenceType recurrenceType;
  final int? dayOfWeek;                      // 1–7 (Mon–Sun); only if weekly
  final int? dayOfMonth;                     // 1–31; only if monthly
  final Criteria criteria;
  final EnforcementProfile profile;
  final bool negate;                         // If true, cancels the matching recurring event on referenceDate
  final String? anchorId;                    // UUID of target anchor (for getAway/stayNear)
  final String? wifiSSID;                    // Target network name (for getOnWifi/getOffWifi)
  final List<String> beepAnchors;           // Anchor UUIDs to beep if watch removed
  final AnchorEnforcementProfile? anchorProfile;  // Required if beepAnchors is non-empty
  final Color color;                         // UI-only calendar block color
}

appearsOnDate(date): Returns true if this automation is active on the given date: - once: date == referenceDate - daily: date >= referenceDate - weekly: date >= referenceDate AND day_of_week(date) == dayOfWeek - monthly: date >= referenceDate AND day_of_month(date) == dayOfMonth

Validation (enforced in AddAutomationModal before save): - If WiFi criteria: wifiSSID must be non-empty. - If anchor criteria: anchorId must be non-null. - If beepAnchors is non-empty: anchorProfile must be non-null. - endTime must be after startTime.

Persistence: JSON-encoded, stored in SharedPreferences under "automations_v2".


3. Services

3.1 BluetoothService (lib/services/bluetooth_service.dart)

Singleton. Owns the device history (watches + anchors) and BLE scan management.

State: - _deviceHistory: List<BluetoothDeviceModel> — all known devices.

Initialization: initialize() loads _deviceHistory from SharedPreferences on app start.

Key methods:

Method Behavior
deviceHistory Getter: all devices
watches Getter: filtered by deviceType == watch
anchors Getter: filtered by deviceType == anchor
startScan() Opens a 15-second BLE scan; returns Stream<List<ScanResult>>
stopScan() Terminates the active scan
classifyDevice(result) Returns DeviceType based on service UUIDs or iBeacon manufacturer data (company ID 0xFFFF)
extractAnchorUuid(result) Parses iBeacon manufacturer data to return the anchor's UUID string, or null
addOrUpdateDevice(device) Upserts by id, persists to SharedPreferences
updateDeviceStatus(id, connected, rssi) Updates connection and signal fields, persists
updateAnchorIp(anchorId, ipAddress) Sets IPv4 on an anchor record, persists
getSignalStrength(rssi) Returns label string: "Excellent" / "Good" / "Fair" / "Weak"

iBeacon detection:

manufacturerData[0xFFFF] exists
AND data.length >= 23
AND data[0] == 0x02   // iBeacon type
AND data[1] == 0x15   // iBeacon length
→ Extract UUID from bytes [2:18]


3.2 WatchService (lib/services/watch_service.dart)

Singleton. Manages the active GATT connection to a single watch.

Connection state: - _device: BluetoothDevice? - _gattService: BluetoothService? - 7 characteristic references (bound on connect):

Field UUID Mode
_wifiCredChar watchWifiCredCharUuid (0x0011) Write
_schedCtrlChar watchSchedCtrlCharUuid (0x0012) Write + Notify
_schedDataChar watchSchedDataCharUuid (0x0013) Write
_settingsChar watchSettingsCharUuid (0x0014) Write + Notify
_seenAnchorsChar watchSeenAnchorsCharUuid (0x0015) Notify
_statusChar watchStatusCharUuid (0x0016) Notify
_anchorIpChar watchAnchorIpCharUuid (0x0017) Write + Notify

Streams (broadcast): - statusStream: Stream<WatchStatus> — emits on every status notification. - seenAnchorsStream: Stream<List<SeenAnchorInfo>> — emits on every seen-anchors notification.

Models:

class WatchStatus {
  final int activityState;     // 0=dormant, 1=enforcement, 2=dormant_sleep
  final bool btConnected;
  final bool wifiConnected;
  final bool worn;
  final int batteryPct;        // 0xFF if unavailable
  final String? activeEventId; // UUID of active event, or null
}

class SeenAnchorInfo {
  final String uuid;
  final int rssi;              // dBm (raw byte − 128)
  final DateTime lastSeen;
}

Connect lifecycle:

connect(device):
  device.connect(timeout: 10s)
  device.discoverServices()
  _bindCharacteristics()
  _subscribeNotifications():
    Enable notify on _statusChar and _seenAnchorsChar
    Read current values immediately
    Stream updates through _statusController and _seenAnchorsController

disconnect():
  Cancel all subscriptions
  device.disconnect()
  Clear all characteristic references

Key methods:

Method Payload Response
pushWifiCredentials(ssid, password) JSON {"ssid":"...","password":"..."} written to _wifiCredChar None
pushSettings(disconnectedIsDormant, awayIsDormant, tzOffsetMinutes) [flag(1B), flag(1B), tzOffset(2B LE)] written to _settingsChar 0x01 ack (5s timeout)
pushSchedule(automations) 3-phase protocol (see below) 0x01 on success, 0x00 on failure
pushAnchorIpTable(anchors) [count(1B)] + [uuid(16B) + ipv4(4B) + ts(4B)] × N written to _anchorIpChar 0x01 ack (5s timeout)

Schedule transfer (3-phase):

Phase 1 — BEGIN:
  Write to _schedCtrlChar: [0x01, blob_length(u32 LE)]
  Await notify: [0x01] = ready (10s timeout)

Phase 2 — DATA:
  For each 20-byte chunk of blob:
    Write to _schedDataChar (no response)
    Wait 10ms

Phase 3 — END:
  Write to _schedCtrlChar: [0x02, crc32(u32 LE)]
  Await notify: [0x01] = success, [0x00] = CRC mismatch (10s timeout)

Return: true if phase 1 and phase 3 both ACK with 0x01.


3.3 AutomationService (lib/services/automation_service.dart)

Singleton. CRUD and persistence for automations.

State: _automations: List<Automation>

Persistence: SharedPreferences key "automations_v2" (JSON array).

Key methods:

Method Behavior
initialize() Loads automations from SharedPreferences
automations Getter
addAutomation(a) Appends and persists
updateAutomation(a) Replaces by id and persists
deleteAutomation(id) Removes by id and persists
getAutomationsForDate(date) Filters by appearsOnDate(date)
calculateAutomationLayouts(automations) Returns Map<String, AutomationLayout> for non-overlapping column placement in the calendar grid

Layout algorithm: Sorts by startMinutes (asc) then durationMinutes (desc). Greedily assigns each automation to the first non-overlapping column.


3.4 DebugLogService (lib/services/debug_log_service.dart)

Singleton. Real-time BLE packet log with a max of 300 entries.

class DebugLogEntry {
  final DateTime timestamp;
  final String source;    // e.g. "status", "seen_anchors", "schedule"
  final String decoded;   // Human-readable interpretation
  final String hex;       // Space-separated hex bytes
}

Methods: log(source, decoded, rawBytes), clear(), stream (broadcast).

Logging points: Watch connection events, schedule transfer phases, status/seen-anchors notifications, anchor IP pushes.


4. Screens

4.1 Main (lib/main.dart)

App entry point. Initializes services, builds the 4-tab scaffold.

On init: - Calls BluetoothService.initialize() and AutomationService.initialize(). - Subscribes to WatchService.seenAnchorsStream: - For each SeenAnchorInfo, creates or updates a BluetoothDeviceModel (type: anchor, isConnected: false, bleRemoteId: null). - Calls BluetoothService.addOrUpdateDevice().


4.2 Devices Screen (lib/screens/devices_screen.dart)

Purpose: Scan for BLE devices, manage watch connection, push schedules, identify anchors.

State: - _isScanning: bool - _isPushing: bool - _statusMsg: String? - _watchStatus: WatchStatus? - _statusSub: StreamSubscription<WatchStatus>?

Layout: 1. Optional status banner (operation feedback). 2. Watches section: One card per known watch. - Shows: device icon, name, connection state. - When connected: live status panel (activity state, WiFi, worn, battery, active event UUID). - Buttons: "Connect" / "Disconnect", and when connected: "Push Schedule Now". - Settings icon (gear button) on each card → opens DeviceSettingsModal (see Section 10). 3. Anchors section: One card per known anchor. - Shows: device icon, name, UUID prefix, RSSI + signal label, IPv4 if known. - "Beep" icon button → identify anchor over BLE. - Settings icon (gear button) on each card → opens DeviceSettingsModal (see Section 10).

Actions:

_startScan() 1. Call BluetoothService.startScan(). 2. For each ScanResult: classify device type, extract anchor UUID if applicable, create BluetoothDeviceModel with bleRemoteId populated, call addOrUpdateDevice(). 3. Scan auto-stops after 15 seconds.

_connectWatch(model) 1. Retrieve BluetoothDevice from flutter_blue_plus using model.bleRemoteId. 2. Call WatchService.connect(fbpDevice). 3. Call BluetoothService.updateDeviceStatus(model.id, true, model.rssi). 4. Subscribe to WatchService.statusStream → update _watchStatus via setState().

_disconnectWatch() 1. Call WatchService.disconnect(). 2. Call BluetoothService.updateDeviceStatus(model.id, false, model.rssi).

_pushSchedule() 1. Call WatchService.pushSchedule(AutomationService().automations). 2. Show success or failure message in status banner.

_identifyAnchor(model) 1. Requires model.bleRemoteId (device must have been directly scanned). 2. Connect to anchor BLE device. 3. Discover services, find anchorServiceUuid. 4. Write 0x01 to anchorIdentifyCharUuid (write no-response). 5. Disconnect.


4.3 Automations Screen (lib/screens/automations_screen.dart)

Purpose: Visual day-based automation editor.

Layout: 1. Date navigator (previous / today / next day). 2. 24-hour calendar grid: - Left column: hour labels (12 AM–11 PM, 60px per hour). - Main area: AutomationBlock widgets positioned by start time and duration. 3. FAB (orange): open AddAutomationModal.

Actions:

_addAutomation() - Show AddAutomationModal(initialDate: _selectedDate). - If result is non-null: call AutomationService.addAutomation(), setState().

_editAutomation(automation) - Show AddAutomationModal(existingAutomation: automation). - If result is non-null: call AutomationService.updateAutomation(), setState().

Rendering blocks: - Call getAutomationsForDate(_selectedDate) → list. - Call calculateAutomationLayouts(list) → layout map. - Render one AutomationBlock per automation using layout for column positioning.


4.4 Settings Screen (lib/screens/settings_screen.dart)

Purpose: Watch configuration and IP table management.

Sections:

  1. Connection Status: Shows "Watch connected" or "Not connected" (read-only).

  2. Push WiFi Credentials: - Text fields: SSID, password (toggleable visibility). - "Send to Watch" → WatchService.pushWifiCredentials(ssid, password).

  3. Watch Behaviour: - Toggle: "Enforce when disconnected from phone" (DISCONNECTED_IS_DORMANT). - Toggle: "Enforce when on WiFi but no BLE" (AWAY_IS_DORMANT). - Timezone slider: UTC−12 to UTC+14 in 15-minute increments. - "Save & Push to Watch" → WatchService.pushSettings(...) + persist to SharedPreferences.

  4. Schedule: - Shows event count. - "Push Schedule to Watch" → WatchService.pushSchedule(automations).

  5. Anchor IP Addresses: - If no anchors: "No anchors discovered". - Otherwise: text field per anchor for IPv4 input. - "Push IPs to Watch" → for each non-empty IP field: BluetoothService.updateAnchorIp(), then WatchService.pushAnchorIpTable().

Persistence keys: "setting_disc_dorm" (bool), "setting_away_dorm" (bool), "setting_tz" (int).


4.5 Debug Screen (lib/screens/debug_screen.dart)

Purpose: Live BLE packet inspector.

Layout: Scrollable list of DebugLogEntry tiles. Each tile: timestamp + source badge + decoded text + hex bytes.

Actions: Auto-scroll toggle, copy all to clipboard, clear log.


5. Widgets

5.1 AddAutomationModal (lib/widgets/add_automation_modal.dart)

Full-screen dialog for creating or editing an automation.

Props: - initialDate: DateTime - existingAutomation: Automation? (if provided, pre-fills form)

Sections (in order): 1. Date navigator header. 2. Start/end time pickers (tap to open system showTimePicker()). 3. Recurrence selector: Once / Daily / Weekly / Monthly. 4. Criteria dropdown: Stay Near / Get Away / Get on WiFi / Get off WiFi. 5. Target input: SSID text field (WiFi) or anchor dropdown (anchor-based). 6. Enforcement profile dropdown (9 options). 7. Beep anchors: checkbox list of known anchors; if any selected, shows anchor profile dropdown. 8. Color picker: 9 swatches. 9. Negate toggle with explanation. 10. Cancel and Save/Update buttons.

Validation (_canSave): WiFi criteria requires non-empty SSID; anchor criteria requires non-null anchor ID.

On save: Builds Automation object, pops with result.


5.2 AutomationBlock (lib/widgets/automation_block.dart)

A Positioned colored block in the calendar grid.

Props: automation, layout, onTap.

Positioning: - top = startMinutes px - height = durationMinutes px - left and width derived from layout.columnIndex / layout.totalColumns

Content (adaptive to height): 1. Target label (SSID, anchor name, or UUID prefix) — always shown. 2. Time range string — if height > 30px. 3. Criteria label — if height > 60px.


6. Utilities

6.1 BLE Constants (lib/utils/ble_constants.dart)

// Anchor GATT service
anchorServiceUuid      = "4a0f0001-f8ce-11ee-8001-020304050607"
anchorIdentifyCharUuid = "4a0f0002-f8ce-11ee-8001-020304050607"  // Write no-response
anchorWifiCredCharUuid = "4a0f0003-f8ce-11ee-8001-020304050607"  // Write with response
anchorSettingsCharUuid = "4a0f0004-f8ce-11ee-8001-020304050607"  // Write with response
anchorSchedCtrlCharUuid= "4a0f0005-f8ce-11ee-8001-020304050607"  // Write with response
anchorSchedDataCharUuid= "4a0f0006-f8ce-11ee-8001-020304050607"  // Write no-response
anchorToggleCharUuid   = "4a0f0007-f8ce-11ee-8001-020304050607"  // Read + Write with response

// Watch GATT service
watchServiceUuid          = "4a0f0010-f8ce-11ee-8001-020304050607"
watchWifiCredCharUuid     = "4a0f0011-f8ce-11ee-8001-020304050607"  // Write no-response
watchSchedCtrlCharUuid    = "4a0f0012-f8ce-11ee-8001-020304050607"  // Write + Notify
watchSchedDataCharUuid    = "4a0f0013-f8ce-11ee-8001-020304050607"  // Write no-response
watchSettingsCharUuid     = "4a0f0014-f8ce-11ee-8001-020304050607"  // Write + Notify
watchSeenAnchorsCharUuid  = "4a0f0015-f8ce-11ee-8001-020304050607"  // Notify
watchStatusCharUuid       = "4a0f0016-f8ce-11ee-8001-020304050607"  // Notify
watchAnchorIpCharUuid     = "4a0f0017-f8ce-11ee-8001-020304050607"  // Write + Notify

// iBeacon manufacturer data
impulseCompanyId = 0xFFFF
iBeaconType      = 0x02
iBeaconLength    = 0x15

6.2 ScheduleEncoder (lib/utils/schedule_encoder.dart)

Handles binary serialization of automations and CRC32 computation.

Functions:

Function Returns Notes
encodeBlob(automations) Uint8List Serializes automations (no CRC)
encodeWithCrc(automations) Uint8List encodeBlob() + 4-byte CRC32 appended
crc32(data) int ISO 3309 polynomial 0xEDB88320
uuidToBytes(uuid) Uint8List Parses UUID string → 16 bytes
generateUuid() String Cryptographically random UUID v4

Blob encoding per automation:

[event count: u16 LE]
for each automation:
  [id:               16 bytes]
  [referenceDate:    i64 LE, Unix seconds]
  [startMinutes:     u16 LE]
  [endMinutes:       u16 LE]
  [recurrenceType:   u8]
  [dayOfWeek:        u8, 0 if N/A]
  [dayOfMonth:       u8, 0 if N/A]
  [criteria:         u8]
  [profile:          u8]
  [anchorProfile:    u8, 0xFF if null]
  [negate:           u8]
  [hasAnchor:        u8, 0 or 1]
  [anchorId:         16 bytes, zero-filled if absent]
  [wifiSSID_len:     u8]
  [wifiSSID:         UTF-8 bytes, omitted if len=0]
  [beepAnchorCount:  u8]
  [beepAnchor UUIDs: 16 bytes each]


7. Theme (lib/theme/app_theme.dart)

Dark theme with orange accent.

Token Hex Usage
darkGrey #2B2B2B AppBar, BottomNav background
lightOrange #FF9D5C Primary accent, FAB, buttons, highlights
backgroundGrey #1A1A1A Scaffold background
cardGrey #3A3A3A Card and input field background
textWhite #FFFFFF Primary text
textGrey #B0B0B0 Secondary text, hints

Cards: rounded 12px, elevation 2. Text scale: headlineLarge 24px bold, headlineMedium 20px bold, bodyLarge 16px, bodyMedium 14px.


8. BLE Wire Formats

Watch GATT (app → watch)

Characteristic Write Payload Notify Response
WiFi Credentials JSON {"ssid":"...","password":"..."} —
Settings [disc_dorm(1B), away_dorm(1B), tz_offset(2B LE signed)] 0x01 success
Schedule Control [0x01, length(4B LE)] (BEGIN) or [0x02, crc(4B LE)] (END) 0x01 success
Schedule Data Raw blob chunks (≤20 bytes each) —
Anchor IP Table [count(1B)] + ([uuid(16B)] + [ipv4(4B)] + [ts(4B LE)])×N 0x01 success

Watch GATT (watch → app, notify)

Characteristic Payload
Seen Anchors [count(1B)] + ([uuid(16B)] + [rssi(1B, actual+128)] + [ts(4B LE)])×N
Watch Status [state(1B)] + [bt(1B)] + [wifi(1B)] + [worn(1B)] + [battery(1B)] + [event_uuid(16B)] + optional unreachable anchor list

Anchor GATT (app → anchor)

Characteristic Write Payload Response
Identify Any (ignored) — (no response)
WiFi Credentials JSON {"ssid":"...","password":"..."} 0x01 success / 0x00 failure
Settings [max_beep_minutes(2B LE)] 0x01
Schedule Control Same 3-phase protocol as watch 0x01 / 0x00
Toggle 0x00 (close) or 0x01 (open) 0x01 success / 0x02 rejected

Anchor iBeacon Advertisement

Manufacturer data (company 0xFFFF):
  [type:     0x02]
  [length:   0x15]
  [uuid:     16 bytes]
  [major:    2 bytes BE = 0x4A0F]
  [minor:    2 bytes BE = 0x0000]
  [txPower:  1 byte]

9. Local Storage

SharedPreferences Key Type Content
"paired_devices" JSON string Array of BluetoothDeviceModel (watches + anchors)
"automations_v2" JSON string Array of Automation
"setting_disc_dorm" bool Enforce when disconnected from phone
"setting_away_dorm" bool Enforce when on WiFi only
"setting_tz" int Timezone offset in minutes (−720 to +840)

10. Device Settings & Anchor Servo Control

This section documents the per-device settings (rename, anchor toggle) accessible from the Devices screen.


10.1 Overview of Changes

Area Change
lib/utils/ble_constants.dart Add anchorToggleCharUuid
lib/services/anchor_service.dart New singleton service for anchor BLE control operations
lib/screens/devices_screen.dart Add settings gear icon to each device card
lib/widgets/device_settings_modal.dart New modal: display name editor + anchor servo control

10.2 New BLE Constant

Add to lib/utils/ble_constants.dart:

const anchorToggleCharUuid = "4a0f0007-f8ce-11ee-8001-020304050607";

This UUID matches ANCHOR_TOGGLE_CHAR_UUID defined in the firmware spec (Section 4.4).


10.3 New Service: AnchorService (lib/services/anchor_service.dart)

Singleton. Handles direct BLE connections to anchor devices for control operations (separate from the passive iBeacon scanning managed by BluetoothService).

Enum:

enum AnchorToggleResult {
  success,          // Firmware responded 0x01
  rejected,         // Firmware responded 0x02 (enforcement active)
  connectionError,  // Could not connect or discover service/characteristic
}

Method: sendToggle(bleRemoteId, value)

sendToggle(bleRemoteId: String, value: int) -> Future<AnchorToggleResult>

  value: 0 = close, 1 = open

  1. Obtain a BluetoothDevice from flutter_blue_plus using bleRemoteId.
  2. Connect to the device (timeout: 10s).
     On failure: return AnchorToggleResult.connectionError.
  3. Discover services.
     On failure or if anchorServiceUuid not found: disconnect, return connectionError.
  4. Find the anchorToggleCharUuid characteristic.
     If not found: disconnect, return connectionError.
  5. Write [value] (1 byte) to the characteristic (write with response).
     Await the response byte:
       0x01 → success
       0x02 → rejected
       Other or timeout → connectionError
  6. Disconnect.
  7. Return the appropriate AnchorToggleResult.

10.4 Updated: Devices Screen (lib/screens/devices_screen.dart)

Change: Add a settings gear IconButton to each device card (both watch cards and anchor cards).

Placement: In the card's trailing area or action row, alongside the existing "Connect"/"Beep" buttons.

On tap:

showModalBottomSheet(
  context: context,
  builder: (_) => DeviceSettingsModal(device: model),
);

After the modal closes, call setState() so that any name change is reflected immediately in the card.


10.5 New Widget: DeviceSettingsModal (lib/widgets/device_settings_modal.dart)

A ModalBottomSheet for per-device settings. Displayed when the user taps the gear icon on any device card.

Props:

class DeviceSettingsModal extends StatefulWidget {
  final BluetoothDeviceModel device;
}

State:

String _nameInput;           // Initialized from device.name
bool _isBusy;                // True while a BLE operation is in progress
String? _statusMsg;          // Feedback message to display

Layout (in order):

  1. Header: Title "Device Settings", styled to match app theme.

  2. Display Name section: - Label: "Display Name" - TextField pre-filled with device.name. - "Save" button (enabled when _nameInput differs from device.name and is non-empty). - On save:

    1. Create updated BluetoothDeviceModel with name = _nameInput.
    2. Call BluetoothService().addOrUpdateDevice(updatedModel).
    3. Show brief confirmation message.
  3. Servo Control section (anchors only — rendered if device.deviceType == DeviceType.anchor): - Section header: "Servo Lock" - Descriptive text: "Open the anchor strap lock to reposition the anchor. The lock cannot be opened during an active enforcement event." - Guard: if device.bleRemoteId == null, show a warning instead: "This anchor has not been directly scanned. Tap 'Scan' on the Devices tab to discover it before using servo control." Do not render the control buttons. - If bleRemoteId is available, render two buttons side by side:

    • "Open" button: outlined, sends value = 1.
    • "Close" button: filled, sends value = 0.
    • Both buttons disabled while _isBusy == true.
    • On either button tap:
    _sendToggle(value):
      setState: _isBusy = true, _statusMsg = null
      result = await AnchorService().sendToggle(device.bleRemoteId!, value)
      setState: _isBusy = false
      switch result:
        success:         _statusMsg = value == 1 ? "Anchor opened." : "Anchor closed."
        rejected:        _statusMsg = "Cannot open: anchor is in an active enforcement event."
        connectionError: _statusMsg = "Could not connect to anchor. Make sure it is nearby and powered on."
    
  • Display _statusMsg below the buttons (using the app's secondary text style).
  • A CircularProgressIndicator replaces button content while _isBusy == true.

10.6 Data Flow for Anchor Toggle

User taps "Open" on DeviceSettingsModal
  → DeviceSettingsModal._sendToggle(1)
    → AnchorService().sendToggle(bleRemoteId, 1)
      → flutter_blue_plus.connect(bleRemoteId, timeout: 10s)
      → discoverServices()
      → find anchorServiceUuid → find anchorToggleCharUuid
      → write([0x01]) with response
      ← firmware responds:
          0x01 → servo opens (160°), firmware detaches servo
          0x02 → enforcement active, servo unchanged
      → disconnect()
      ← returns AnchorToggleResult
    ← DeviceSettingsModal updates _statusMsg

10.7 Behavioral Notes

  • Close is always allowed: The "Close" button should never be disabled on enforcement grounds (the firmware always accepts a close command). The rejection message is written to only apply when value == 1 (open).
  • bleRemoteId requirement: Anchors discovered only through the watch's SeenAnchors stream will not have a bleRemoteId. The settings modal must guard against this and prompt the user to scan first.
  • Name persistence is immediate: Saving a display name writes through to BluetoothService and SharedPreferences before the modal closes. The Devices screen must refresh its device list after the modal closes via setState().
  • No persistent servo state in app: The app does not track or cache whether the servo is open or closed. The Toggle characteristic is readable per the firmware spec, but reading it is deferred to a future spec version. For now, the UI is stateless with respect to servo position.
  • One connection at a time: AnchorService.sendToggle connects, operates, and disconnects in a single call. It does not maintain a persistent connection to the anchor.

End of specification v0.2