diff --git a/.build_version b/.build_version index 7dea76e..26aaba0 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.0.1 +1.2.0 diff --git a/.gitignore b/.gitignore index 25d86f8..d7c37fe 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,7 @@ migrate_working_dir/ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +.vscode/ # Flutter/Dart/Pub related **/doc/api/ @@ -75,6 +72,7 @@ ios/Flutter/flutter_export_environment.sh *.g.dart # Debug logs +debug/ debuglog/ meshmapper-debug-*.txt diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index b7bd3c4..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Flutter: Build & Serve Web", - "type": "shell", - "command": " - ", - "isBackground": true, - "problemMatcher": [], - "group": "build", - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "dedicated", - "showReuseMessage": false - } - } - ] -} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..89cf71b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,104 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a welcoming experience for everyone, regardless of background or +identity. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior: + +- Trolling, insulting or derogatory comments, and personal attacks +- Public or private harassment of any kind +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of unacceptable behavior may be reported to the project maintainers at +the [MeshMapper Project issue tracker](https://github.com/MeshMapper/MeshMapper_Project/issues). + +All complaints will be reviewed and investigated promptly and fairly. All +community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels. +Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, or targeted conduct +against an individual or group. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ac71218..b7b1ad2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -295,10 +295,21 @@ Keeps the screen on during auto-ping to prevent device sleep during wardriving s ### Packet Structure - Custom binary protocol with header byte (0x11 = GROUP_TEXT, 0x21 = ADVERT) -- Path encoding: hop count + repeater IDs (4 bytes each) +- Path encoding: `pathLen` byte encodes hash size (top 2 bits) + hop count (bottom 6 bits), followed by `hopCount * hashSize` path bytes + - `pathHashSize = (pathLen >> 6) + 1` → 1, 2, 3, or 4 bytes per hop + - `pathHashCount = pathLen & 63` → 0-63 hops - SNR/RSSI metadata in BLE event payload - Encrypted message payload (AES-ECB with channel key) +### Multi-Byte Path Support (v1.14.0+) +- **Purpose**: Expands repeater ID space from 256 (1-byte) to 65K (2-byte) or 16M (3-byte) unique IDs +- **TX mode**: Configured via `CMD_SET_PATH_HASH_MODE = 61 (0x3D)` — `[0x3D][0x00][mode]` where mode=0→1-byte, 1→2-byte, 2→3-byte +- **RX auto-detect**: Each received packet's `pathLen` byte is decoded to determine hash size, regardless of the user's TX setting +- **DeviceInfo**: v10+ firmware includes `path_hash_mode` byte after manufacturer + firmware version fields +- **API enforcement**: Auth response may include `hop_bytes` (1/2/3) to enforce regional path byte size +- **Lifecycle**: Radio mode is set during connection and restored to original on clean disconnect. Unclean disconnect leaves radio in configured mode. +- **Discovery pings**: NOT affected — multi-byte paths apply only to TX/RX channel messages + ## Platform-Specific Notes ### Web (Chrome/Edge only) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..812bab3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,62 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| Latest | Yes | + +Only the latest release on the `main` branch receives security updates. + +## Reporting a Vulnerability + +If you discover a security vulnerability in MeshMapper Flutter App, please report it responsibly. **Do not open a public GitHub issue for security vulnerabilities.** + +### How to Report + +1. Open a **private security advisory** at: + https://github.com/MeshMapper/MeshMapper_Project/security/advisories/new + +2. Include the following information: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +### What to Expect + +- **Acknowledgment**: We will acknowledge receipt of your report within 72 hours. +- **Assessment**: We will assess the severity and impact of the vulnerability. +- **Fix timeline**: Critical issues will be addressed as quickly as possible. We aim to release a fix within 30 days of confirmation. +- **Disclosure**: We will coordinate with you on public disclosure timing after a fix is available. + +## Security Considerations + +### API Key Handling + +- API keys are injected at build time via `--dart-define=API_KEY=...` and are **never** hardcoded in source code. + +### Bluetooth Security + +- The app communicates with MeshCore devices over Bluetooth Low Energy (BLE) using the MeshCore companion protocol. +- Channel messages are encrypted using AES-ECB with SHA-256 derived channel keys (encryption mode is dictated by the MeshCore protocol). + +### Data Privacy + +- GPS location data is sent to the MeshMapper API for community mesh coverage mapping. +- Users must be within a valid geographic zone (server-side validation) to submit data. +- No personal information beyond device name/public key and location data is transmitted. + +## Scope + +The following are **in scope** for security reports: + +- Vulnerabilities in the Flutter application code +- API key or secret exposure +- Authentication or session management issues +- Data leakage or privacy concerns + +The following are **out of scope**: + +- Vulnerabilities in third-party dependencies (report to the upstream project) +- Issues with the MeshCore firmware or radio protocol (report to the MeshCore project) \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index f29e80e..08ef6ef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,15 +15,14 @@ import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/debug_file_logger.dart'; -import 'utils/constants.dart'; import 'utils/debug_logger_io.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Enable debug file logging FIRST on mobile for dev builds + // Enable debug file logging FIRST on mobile to capture early logs // This must happen before DebugLogger.initialize() to capture early logs - if (!kIsWeb && AppConstants.isDevelopmentBuild) { + if (!kIsWeb) { await DebugFileLogger.enable(); } @@ -248,6 +247,14 @@ class _ThemedAppState extends State<_ThemedApp> { return MaterialApp( title: 'MeshMapper', + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.noScaling, + ), + child: child!, + ); + }, theme: ThemeData( colorScheme: lightColorScheme, scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index f2e4bcd..337cab1 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -44,7 +44,7 @@ class ApiQueueItem extends HiveObject { final String heardRepeats; /// Earliest time this item can be uploaded (milliseconds since epoch) - /// TX items have 5-second delay; RX/DISC are immediate + /// All items are immediate; upload timing is controlled by flush timers @HiveField(13) final int canUploadAfter; @@ -81,7 +81,7 @@ class ApiQueueItem extends HiveObject { longitude: longitude, timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), heardRepeats: heardRepeats, - canUploadAfter: DateTime.now().millisecondsSinceEpoch + 5000, // 5 seconds from now + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, ); @@ -138,10 +138,87 @@ class ApiQueueItem extends HiveObject { ); } + /// Create from a successful TRACE ping (targeted zero-hop trace) + /// heardRepeats format: "repeaterId:localSnr:localRssi:remoteSnr" + factory ApiQueueItem.fromTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) { + final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; + return ApiQueueItem( + type: 'TRACE', + latitude: latitude, + longitude: longitude, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + heardRepeats: heardRepeats, + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + } + + /// Create from a failed DISC discovery (no nodes responded) + factory ApiQueueItem.fromDiscDrop({ + required double latitude, + required double longitude, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) { + return ApiQueueItem( + type: 'DISC', + latitude: latitude, + longitude: longitude, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + heardRepeats: 'None', + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + } + /// Convert to API JSON format (matches WebClient exactly) Map toApiJson() { + // For TRACE type, parse the heardRepeats field to extract individual values + if (type == 'TRACE') { + // Format: "repeaterId:localSnr:localRssi:remoteSnr" + final parts = heardRepeats.split(':'); + return { + 'type': type, + 'lat': latitude, + 'lon': longitude, + 'noisefloor': noiseFloor, + 'repeater_id': parts.isNotEmpty ? parts[0] : '', + 'local_snr': parts.length > 1 ? double.tryParse(parts[1]) : null, + 'local_rssi': parts.length > 2 ? int.tryParse(parts[2]) : null, + 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, + 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, + 'external_antenna': externalAntenna, + }; + } + // For DISC type, parse the heardRepeats field to extract individual values if (type == 'DISC') { + // Failed discovery (no nodes responded) + if (heardRepeats == 'None') { + return { + 'type': type, + 'lat': latitude, + 'lon': longitude, + 'noisefloor': noiseFloor, + 'repeater_id': 'None', + 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, + 'external_antenna': externalAntenna, + }; + } + // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" final parts = heardRepeats.split(':'); return { diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 758d4ab..26429be 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -31,7 +31,7 @@ class TxLogEntry { String toCsv() { final eventsStr = events.isEmpty ? 'None' - : events.map((e) => '${e.repeaterId}(${e.snr.toStringAsFixed(2)})').join(','); + : events.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; } } @@ -39,21 +39,23 @@ class TxLogEntry { /// RX Event (repeater that heard a TX ping) class RxEvent { final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double snr; // Signal-to-noise ratio in dB - final int rssi; // RSSI in dBm + final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? rssi; // RSSI in dBm (null for CARpeater pass-through) RxEvent({ required this.repeaterId, - required this.snr, - this.rssi = 0, + this.snr, + this.rssi, }); /// Get SNR color severity (red, orange, green) + /// Returns null for CARpeater pass-through (no signal data) /// Reference: getSnrSeverityClass() in wardrive.js - SnrSeverity get severity { - if (snr <= -1) { + SnrSeverity? get severity { + if (snr == null) return null; + if (snr! <= -1) { return SnrSeverity.poor; // Red: -12 to -1 dB - } else if (snr <= 5) { + } else if (snr! <= 5) { return SnrSeverity.fair; // Orange: 0 to 5 dB } else { return SnrSeverity.good; // Green: 6 to 13+ dB @@ -66,8 +68,8 @@ class RxEvent { class RxLogEntry { final DateTime timestamp; final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double snr; // Signal-to-noise ratio in dB - final int rssi; // Received signal strength indicator in dBm + final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) final int pathLength; // Number of hops final int header; // Packet header byte final double latitude; @@ -76,8 +78,8 @@ class RxLogEntry { RxLogEntry({ required this.timestamp, required this.repeaterId, - required this.snr, - required this.rssi, + this.snr, + this.rssi, required this.pathLength, required this.header, required this.latitude, @@ -97,10 +99,12 @@ class RxLogEntry { } /// Get SNR color severity - SnrSeverity get severity { - if (snr <= -1) { + /// Returns null for CARpeater pass-through (no signal data) + SnrSeverity? get severity { + if (snr == null) return null; + if (snr! <= -1) { return SnrSeverity.poor; - } else if (snr <= 5) { + } else if (snr! <= 5) { return SnrSeverity.fair; } else { return SnrSeverity.good; @@ -109,12 +113,67 @@ class RxLogEntry { /// Get CSV row String toCsv() { - return '${timestamp.toIso8601String()},$repeaterId,$snr,$rssi,' + return '${timestamp.toIso8601String()},$repeaterId,${snr ?? 'null'},${rssi ?? 'null'},' '$pathLength,0x${header.toRadixString(16).padLeft(2, '0')},' '$latitude,$longitude'; } } +/// Trace Log Entry (targeted zero-hop trace result) +class TraceLogEntry { + final DateTime timestamp; + final double latitude; + final double longitude; + final String targetRepeaterId; + final int? noiseFloor; + final double? localSnr; + final double? remoteSnr; + final int? localRssi; + final bool success; + + TraceLogEntry({ + required this.timestamp, + required this.latitude, + required this.longitude, + required this.targetRepeaterId, + this.noiseFloor, + this.localSnr, + this.remoteSnr, + this.localRssi, + required this.success, + }); + + /// Get formatted timestamp (HH:MM:SS) + String get timeString { + return '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}'; + } + + /// Get formatted location (5 decimal places) + String get locationString { + return '${latitude.toStringAsFixed(5)},${longitude.toStringAsFixed(5)}'; + } + + /// Get SNR color severity based on local SNR + SnrSeverity? get severity { + if (localSnr == null) return null; + if (localSnr! <= -1) { + return SnrSeverity.poor; + } else if (localSnr! <= 5) { + return SnrSeverity.fair; + } else { + return SnrSeverity.good; + } + } + + /// Get CSV row + String toCsv() { + return '${timestamp.toIso8601String()},$targetRepeaterId,${localSnr ?? 'null'},${localRssi ?? 'null'},' + '${remoteSnr ?? 'null'},$latitude,$longitude,${noiseFloor ?? ''},$success'; + } +} + /// SNR Severity levels for color coding enum SnrSeverity { poor, // Red: SNR ≤ -1 dB @@ -122,6 +181,47 @@ enum SnrSeverity { good, // Green: SNR > 5 dB } +/// Ping type for unified log view +enum PingLogType { tx, rx, disc, trace } + +/// Wrapper for unified chronological ping log view +class UnifiedPingLogEntry implements Comparable { + final PingLogType type; + final DateTime timestamp; + final dynamic entry; + + UnifiedPingLogEntry({required this.type, required this.timestamp, required this.entry}); + + TxLogEntry get asTx => entry as TxLogEntry; + RxLogEntry get asRx => entry as RxLogEntry; + DiscLogEntry get asDisc => entry as DiscLogEntry; + TraceLogEntry get asTrace => entry as TraceLogEntry; + + @override + int compareTo(UnifiedPingLogEntry other) => other.timestamp.compareTo(timestamp); + + String get timeString => switch (type) { + PingLogType.tx => asTx.timeString, + PingLogType.rx => asRx.timeString, + PingLogType.disc => asDisc.timeString, + PingLogType.trace => asTrace.timeString, + }; + + String get locationString => switch (type) { + PingLogType.tx => asTx.locationString, + PingLogType.rx => asRx.locationString, + PingLogType.disc => asDisc.locationString, + PingLogType.trace => asTrace.locationString, + }; + + String toCsv() => switch (type) { + PingLogType.tx => 'TX,${asTx.toCsv()}', + PingLogType.rx => 'RX,${asRx.toCsv()}', + PingLogType.disc => 'DISC,${asDisc.toCsv()}', + PingLogType.trace => 'TRC,${asTrace.toCsv()}', + }; +} + /// User Error Entry for error log class UserErrorEntry { final DateTime timestamp; @@ -197,7 +297,7 @@ class DiscLogEntry { /// Discovered node entry for log display class DiscoveredNodeEntry { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") + final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") final String nodeType; // "REPEATER" or "ROOM" final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index a921216..9c6b574 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -35,6 +35,12 @@ enum PingEventType { @HiveField(4) discFail, // Grey: Discovery no response + + @HiveField(5) + traceSuccess, // Cyan: Trace got response + + @HiveField(6) + traceFail, // Grey: Trace no response } /// Repeater info for graph markers @@ -99,6 +105,8 @@ class PingEventMarker extends HiveObject { PingEventType.rx => Colors.blue, PingEventType.discSuccess => Colors.purple, PingEventType.discFail => Colors.grey, + PingEventType.traceSuccess => Colors.cyan, + PingEventType.traceFail => Colors.grey, }; /// Get a display label for this event type @@ -108,6 +116,8 @@ class PingEventMarker extends HiveObject { PingEventType.rx => 'RX', PingEventType.discSuccess => 'DISC Success', PingEventType.discFail => 'DISC Fail', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Fail', }; } @@ -154,6 +164,7 @@ class NoiseFloorSession extends HiveObject { String get modeDisplay => switch (mode) { 'active' => 'Active Mode', 'hybrid' => 'Hybrid Mode', + 'targeted' => 'Trace Mode', _ => 'Passive Mode', }; diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 2425d60..0e42d08 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -111,14 +111,14 @@ class RxPing { /// Repeater that heard a TX ping (from echo tracking) class HeardRepeater { final String repeaterId; // Hex ID (e.g., "4e", "77") - final double snr; // Best SNR observed - final int rssi; // RSSI in dBm + final double? snr; // Best SNR observed (null for CARpeater pass-through) + final int? rssi; // RSSI in dBm (null for CARpeater pass-through) final int seenCount; // How many times this repeater was heard const HeardRepeater({ required this.repeaterId, - required this.snr, - this.rssi = 0, + this.snr, + this.rssi, this.seenCount = 1, }); } @@ -139,6 +139,7 @@ class PingStats { final int txCount; final int rxCount; final int discCount; // Discovery count (Passive Mode) + final int traceCount; // Trace count (Targeted Mode) final int successfulUploads; final int failedUploads; final int queuedCount; @@ -147,6 +148,7 @@ class PingStats { this.txCount = 0, this.rxCount = 0, this.discCount = 0, + this.traceCount = 0, this.successfulUploads = 0, this.failedUploads = 0, this.queuedCount = 0, @@ -156,6 +158,7 @@ class PingStats { int? txCount, int? rxCount, int? discCount, + int? traceCount, int? successfulUploads, int? failedUploads, int? queuedCount, @@ -164,6 +167,7 @@ class PingStats { txCount: txCount ?? this.txCount, rxCount: rxCount ?? this.rxCount, discCount: discCount ?? this.discCount, + traceCount: traceCount ?? this.traceCount, successfulUploads: successfulUploads ?? this.successfulUploads, failedUploads: failedUploads ?? this.failedUploads, queuedCount: queuedCount ?? this.queuedCount, diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index cdf1867..bbf76bd 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -34,6 +34,9 @@ class Repeater { /// The repeater is active while `now < staleTime`. final int? staleTime; + /// Number of bytes per hop hash for this repeater's path (1, 2, or 3) + final int hopBytes; + const Repeater({ required this.id, required this.hexId, @@ -45,6 +48,7 @@ class Repeater { this.iata, this.createdAt, this.staleTime, + this.hopBytes = 1, }); /// Parse from JSON object in repeaters.json @@ -78,6 +82,7 @@ class Repeater { iata: json['iata'] as String?, createdAt: createdAt, staleTime: staleTime, + hopBytes: (json['hop_bytes'] as int?) ?? 1, ); } @@ -93,6 +98,7 @@ class Repeater { 'iata': iata, 'created_at': createdAt, 'stale_time': staleTime, + 'hop_bytes': hopBytes, }; } @@ -130,6 +136,17 @@ class Repeater { /// Check if the repeater has not been heard in the past 24 hours bool get isDead => !isActive; + /// Get display hex ID based on hop bytes (or override). + /// [overrideHopBytes] is used when regional admin enforces a byte size. + String displayHexId({int? overrideHopBytes}) { + final bytes = overrideHopBytes ?? hopBytes; + final hexChars = bytes * 2; // 1 byte = 2 hex chars + if (hexId.length >= hexChars) { + return hexId.substring(0, hexChars).toUpperCase(); + } + return id; // Fallback to short numeric ID + } + @override String toString() => 'Repeater(id=$id, name=$name, enabled=$isEnabled)'; } diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 3eb4f57..4063fc8 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -64,6 +64,27 @@ class UserPreferences { /// Map rotation lock (disable rotation gestures) final bool mapRotationLocked; + /// Disable RSSI carpeater filter (allow all signal strengths) + final bool disableRssiFilter; + + /// Anonymous mode: rename companion to "Anonymous" during wardriving + final bool anonymousMode; + + /// Discovery drop: count failed discoveries as failed pings and report to API + final bool discDropEnabled; + + /// Delete wardriving channel from radio on disconnect + final bool deleteChannelOnDisconnect; + + /// Minimum ping distance in meters (25m floor, user can increase) + final int minPingDistanceMeters; + + /// Auto-stop auto-ping after 30 minutes of idle (no movement) + final bool autoStopAfterIdle; + + /// Show top 3 repeaters by SNR on the map during wardriving + final bool showTopRepeaters; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -82,10 +103,17 @@ class UserPreferences { this.closeAppAfterDisconnect = false, this.themeMode = 'dark', this.unitSystem = 'metric', - this.hybridModeEnabled = false, + this.hybridModeEnabled = true, this.mapAutoFollow = false, this.mapAlwaysNorth = true, this.mapRotationLocked = false, + this.disableRssiFilter = false, + this.anonymousMode = false, + this.discDropEnabled = false, + this.deleteChannelOnDisconnect = true, + this.minPingDistanceMeters = 25, + this.autoStopAfterIdle = true, + this.showTopRepeaters = false, }); /// Create from JSON (for persistence) @@ -108,10 +136,17 @@ class UserPreferences { closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', - hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? false, + hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, mapAutoFollow: (json['mapAutoFollow'] as bool?) ?? false, mapAlwaysNorth: (json['mapAlwaysNorth'] as bool?) ?? true, mapRotationLocked: (json['mapRotationLocked'] as bool?) ?? false, + disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, + anonymousMode: (json['anonymousMode'] as bool?) ?? false, + discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, + deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, + minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, + autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, + showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, ); } @@ -139,6 +174,13 @@ class UserPreferences { 'mapAutoFollow': mapAutoFollow, 'mapAlwaysNorth': mapAlwaysNorth, 'mapRotationLocked': mapRotationLocked, + 'disableRssiFilter': disableRssiFilter, + 'anonymousMode': anonymousMode, + 'discDropEnabled': discDropEnabled, + 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, + 'minPingDistanceMeters': minPingDistanceMeters, + 'autoStopAfterIdle': autoStopAfterIdle, + 'showTopRepeaters': showTopRepeaters, }; } @@ -165,6 +207,13 @@ class UserPreferences { bool? mapAutoFollow, bool? mapAlwaysNorth, bool? mapRotationLocked, + bool? disableRssiFilter, + bool? anonymousMode, + bool? discDropEnabled, + bool? deleteChannelOnDisconnect, + int? minPingDistanceMeters, + bool? autoStopAfterIdle, + bool? showTopRepeaters, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -188,6 +237,13 @@ class UserPreferences { mapAutoFollow: mapAutoFollow ?? this.mapAutoFollow, mapAlwaysNorth: mapAlwaysNorth ?? this.mapAlwaysNorth, mapRotationLocked: mapRotationLocked ?? this.mapRotationLocked, + disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, + anonymousMode: anonymousMode ?? this.anonymousMode, + discDropEnabled: discDropEnabled ?? this.discDropEnabled, + deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, + autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, + showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, ); } @@ -213,6 +269,9 @@ class UserPreferences { return '$autoPingInterval seconds'; } + /// Get min ping distance display string + String get minPingDistanceDisplay => '${minPingDistanceMeters}m'; + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -236,12 +295,19 @@ class UserPreferences { other.hybridModeEnabled == hybridModeEnabled && other.mapAutoFollow == mapAutoFollow && other.mapAlwaysNorth == mapAlwaysNorth && - other.mapRotationLocked == mapRotationLocked; + other.mapRotationLocked == mapRotationLocked && + other.disableRssiFilter == disableRssiFilter && + other.anonymousMode == anonymousMode && + other.discDropEnabled == discDropEnabled && + other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && + other.minPingDistanceMeters == minPingDistanceMeters && + other.autoStopAfterIdle == autoStopAfterIdle && + other.showTopRepeaters == showTopRepeaters; } @override int get hashCode { - return Object.hash( + return Object.hashAll([ powerLevel, txPower, externalAntenna, @@ -262,7 +328,14 @@ class UserPreferences { mapAutoFollow, mapAlwaysNorth, mapRotationLocked, - ); + disableRssiFilter, + anonymousMode, + discDropEnabled, + deleteChannelOnDisconnect, + minPingDistanceMeters, + autoStopAfterIdle, + showTopRepeaters, + ]); } /// Check if using imperial units @@ -296,3 +369,8 @@ class AutoPingInterval { static const List values = [fast, normal, slow]; } + +/// Minimum ping distance (meters) +class MinPingDistance { + static const int min = 25; +} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a91f16f..745b96f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -29,6 +29,7 @@ import '../services/gps_service.dart'; import '../services/gps_simulator_service.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; +import '../services/meshcore/crypto_service.dart'; import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; @@ -47,8 +48,13 @@ enum AutoMode { passive, /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + /// Trace Mode: Zero-hop trace to specific repeater + targeted, } +/// Ping type for the top-heard overlay dots +enum OverlayPingType { tx, disc, trace, rx } + /// Result of uploading an offline session enum OfflineUploadResult { /// Upload completed successfully @@ -61,6 +67,8 @@ enum OfflineUploadResult { authFailed, /// Some pings failed to upload partialFailure, + /// Another upload is already in progress + uploadInProgress, } /// Main application state provider @@ -99,10 +107,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; StreamSubscription? _adapterStateSubscription; + StreamSubscription? _connectionSubscription; + StreamSubscription? _gpsStatusSubscription; + StreamSubscription? _gpsPositionSubscription; // GPS state GpsStatus _gpsStatus = GpsStatus.permissionDenied; @@ -114,7 +126,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Device info DeviceModel? _deviceModel; String? _manufacturerString; + String? _firmwareVersionString; String? _devicePublicKey; + String? _offlineContactUri; /// BLE device name (e.g., "MeshCore-MrAlders0n_Elecrow") String? get connectedDeviceName => _bluetoothService.connectedDevice?.name; @@ -131,6 +145,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { PingStats _pingStats = const PingStats(); bool _autoPingEnabled = false; AutoMode _autoMode = AutoMode.active; + DateTime? _idleAutoStopReference; + static const Duration _autoStopIdleTimeout = Duration(minutes: 30); bool _isPingSending = false; // True immediately when ping button clicked int _queueSize = 0; int? _currentNoiseFloor; @@ -153,6 +169,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _txLogEntries = []; final List _rxLogEntries = []; final List _discLogEntries = []; + final List _traceLogEntries = []; + + // Top repeaters overlay — updated live on each ping event + List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + ({String repeaterId, double snr})? _rxOverlaySlot; + Timer? _rxOverlayWindowTimer; + + // Targeted mode state + String? _targetRepeaterId; // User error log entries final List _errorLogEntries = []; @@ -160,6 +185,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // User preferences UserPreferences _preferences = const UserPreferences(); + // Anonymous mode state + String? _originalDeviceName; // Real name stored before rename + bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -167,6 +196,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _antennaRestoredFromDevice = false; bool get antennaRestoredFromDevice => _antennaRestoredFromDevice; + /// Per-device power overrides: maps companion name → {powerLevel, txPower} + Map> _devicePowerOverrides = {}; + + /// Whether the current power setting was auto-restored from a saved override + bool _powerRestoredFromDevice = false; + bool get powerRestoredFromDevice => _powerRestoredFromDevice; + // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; @@ -199,6 +235,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _maintenanceUrl; Timer? _maintenanceCheckTimer; + // Tile refresh after upload + int _overlayCacheBust = 0; + Timer? _tileRefreshTimer; + // Auth type from API response (API, Mesh, Manual) String? _authType; @@ -212,8 +252,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _reconnectAttempt = 0; Timer? _reconnectTimer; Timer? _reconnectTimeoutTimer; + Timer? _restoreAutoPingTimer; + Timer? _offlineAutoSaveTimer; + Timer? _zoneRefreshTimer; bool _autoPingWasEnabled = false; AutoMode _autoModeBeforeReconnect = AutoMode.active; + int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); @@ -222,6 +266,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _mapNavigationTrigger = 0; // Increment to trigger navigation bool _requestMapTabSwitch = false; // Request switch to map tab bool _requestErrorLogSwitch = false; // Request switch to error log tab + bool _requestConnectionTabSwitch = false; // Request switch to connection tab // Repeater markers state List _repeaters = []; @@ -231,6 +276,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Regional channels from API (for UI display) List _regionalChannels = []; + // Regional scope from API (for UI display and flood filtering) + String? _scope; + + // Path hash mode tracking (for multi-byte path support) + int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) + bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected + int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) + // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; List _storedNoiseFloorSessions = []; @@ -252,6 +306,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); + } else if (state == AppLifecycleState.paused) { + debugLog('[APP] App paused (backgrounded)'); + // Save offline pings immediately on pause to prevent data loss if OS kills app + if (_preferences.offlineMode && _apiQueueService.offlinePingCount > 0) { + _autoSaveOfflinePings(); + } } } @@ -265,6 +325,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStep get connectionStep => _connectionStep; String? get connectionError => _connectionError; bool get isAuthError => _isAuthError; + bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; @@ -273,6 +334,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; DeviceModel? get deviceModel => _deviceModel; String? get manufacturerString => _manufacturerString; + String? get firmwareVersionString => _firmwareVersionString; String? get devicePublicKey => _devicePublicKey; PingStats get pingStats => _pingStats; bool get autoPingEnabled => _autoPingEnabled; @@ -284,6 +346,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isPendingDisable => _pingService?.pendingDisable ?? false; /// True when running any mode that does TX (Active or Hybrid) bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + /// True when running Trace Mode (zero-hop trace) + bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; + String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; int? get currentBatteryPercent => _currentBatteryPercent; @@ -291,14 +356,71 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isScanning => _isScanning; List get txPings => List.unmodifiable(_txPings); List get rxPings => List.unmodifiable(_rxPings); + + /// Top 3 repeaters by best SNR from TX/DISC/Trace pings + List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; + /// Best RX observation in the current 5-second window + ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; + + /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. + /// Replaces all 3 slots entirely (no carryover from previous pings). + void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { + final bestSnr = {}; + for (final r in current) { + final key = r.repeaterId.toUpperCase(); + if (!bestSnr.containsKey(key) || r.snr > bestSnr[key]!) { + bestSnr[key] = r.snr; + } + } + final fresh = bestSnr.entries + .map((e) => (repeaterId: e.key, snr: e.value, type: type)) + .toList() + ..sort((a, b) => b.snr.compareTo(a.snr)); + _topRepeatersOverlay = fresh.take(3).toList(); + } + + /// Update the RX overlay slot with a 5-second rolling window (best SNR wins). + void _updateRxOverlaySlot(String repeaterId, double snr) { + final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); + if (_rxOverlayWindowTimer?.isActive ?? false) { + if (_rxOverlaySlot == null || snr > _rxOverlaySlot!.snr) { + _rxOverlaySlot = entry; + } + } else { + _rxOverlaySlot = entry; + _rxOverlayWindowTimer = Timer(const Duration(seconds: 5), () { + // Window closed — slot stays until next RX or cleared + }); + } + } + + /// Clear all overlay state (top 3 + RX slot). + void _clearOverlayState() { + _topRepeatersOverlay = []; + _rxOverlaySlot = null; + _rxOverlayWindowTimer?.cancel(); + _rxOverlayWindowTimer = null; + } List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); + List get traceLogEntries => List.unmodifiable(_traceLogEntries); List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get unifiedPingLogEntries { + final merged = [ + ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ]; + merged.sort(); + return merged; + } ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; bool get requestErrorLogSwitch => _requestErrorLogSwitch; + bool get requestConnectionTabSwitch => _requestConnectionTabSwitch; UserPreferences get preferences => _preferences; RememberedDevice? get rememberedDevice => _rememberedDevice; @@ -318,6 +440,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isCheckingZone => _isCheckingZone; String? get zoneName => _currentZone?['name'] as String?; String? get zoneCode => _currentZone?['code'] as String?; + int get overlayCacheBust => _overlayCacheBust; int? get zoneSlotsAvailable => _currentZone?['slots_available'] as int?; int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; @@ -341,6 +464,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isSwitchingMode => _isSwitchingMode; String? get modeSwitchError => _modeSwitchError; + // Anonymous mode getter + bool get isAnonymousRenamed => _isAnonymousRenamed; + // Auto-reconnect getters bool get isAutoReconnecting => _isAutoReconnecting; int get reconnectAttempt => _reconnectAttempt; @@ -351,6 +477,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Regional channels getter (for UI) List get regionalChannels => List.unmodifiable(_regionalChannels); + // Regional scope getter (for UI) + String? get scope => _scope; + // Noise floor session getters NoiseFloorSession? get currentNoiseFloorSession => _currentNoiseFloorSession; List get storedNoiseFloorSessions => @@ -369,10 +498,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get rxAllowed => _apiService.rxAllowed; bool get hasApiSession => _apiService.hasSession; bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; + bool get enforceHybrid => _apiService.enforceHybrid; + bool get enforceDiscDrop => _apiService.enforceDiscDrop; + bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + int get minModeInterval => _apiService.minModeInterval; + bool get enforceHopBytes => _apiService.enforceHopBytes; + int get hopBytes => _hopBytes; + int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get traceHopBytes => _traceHopBytes; + bool get supportsMultiBytePaths => _originalPathHashMode != null; // Offline mode bool get offlineMode => _preferences.offlineMode; List get offlineSessions => _offlineSessionService.sessions; + bool _isUploadingOfflineSession = false; + bool get isUploadingOfflineSession => _isUploadingOfflineSession; // Developer mode bool get developerModeEnabled => _preferences.developerModeEnabled; @@ -429,8 +569,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); - // Auto-enable debug logging for development builds - await _autoEnableDebugLogsIfDevelopmentBuild(); + // Initialize debug logging (enabled by default, respects user preference) + await _initDebugLogs(); // Initialize channel service with Public channel only (regional channels added after auth) await ChannelService.initializePublicChannel(); @@ -453,7 +593,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -469,6 +610,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); + + // Schedule overlay tile refresh after server has time to regenerate tiles + // Cache buster change + notifyListeners triggers flutter_map's reloadImages() + // which updates tile URLs in-place and refetches cleanly + _tileRefreshTimer?.cancel(); + _tileRefreshTimer = Timer(const Duration(seconds: 5), () { + _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; + debugLog('[MAP] Refreshing overlay tiles'); + notifyListeners(); + }); }; // Initialize offline session service @@ -490,6 +641,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Loading preferences...'); await _loadPreferences(); await _loadDeviceAntennaPreferences(); + await _loadDevicePowerOverrides(); // Load last known GPS position for map centering await _loadLastPosition(); @@ -514,7 +666,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); - _bluetoothService.connectionStream.listen((status) async { + await _connectionSubscription?.cancel(); + _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -538,7 +691,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to GPS changes debugLog('[INIT] Setting up GPS status listener...'); - _gpsService.statusStream.listen((status) { + await _gpsStatusSubscription?.cancel(); + _gpsStatusSubscription = _gpsService.statusStream.listen((status) { final previousStatus = _gpsStatus; _gpsStatus = status; @@ -563,7 +717,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); - _gpsService.positionStream.listen((position) async { + await _gpsPositionSubscription?.cancel(); + _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -698,6 +853,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discoveredDevices = []; _connectionError = null; _isAuthError = false; + _isNetworkError = false; notifyListeners(); // Listen for discovered devices using subscription so stopScan() can cancel @@ -708,8 +864,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ).listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { - _discoveredDevices.add(device); - selectedDevice = device; + // Prefer remembered device name (from SelfInfo) over BLE cache + var enrichedDevice = device; + if (_rememberedDevice != null && device.id == _rememberedDevice!.id && + device.name != _rememberedDevice!.name) { + enrichedDevice = DiscoveredDevice( + id: device.id, + name: _rememberedDevice!.name, + rssi: device.rssi, + ); + debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); + } + _discoveredDevices.add(enrichedDevice); + selectedDevice = enrichedDevice; notifyListeners(); } }, @@ -754,6 +921,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _connectionError = null; _isAuthError = false; + _isNetworkError = false; // Clean up any previous connection first if (_meshCoreConnection != null) { @@ -780,8 +948,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; } - // Use SelfInfo name (user's configured name) if available, otherwise fall back to BLE advertisement name - final deviceName = _meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" + if (_preferences.anonymousMode && !_isAnonymousRenamed) { + final realName = _meshCoreConnection!.selfInfo?.name; + if (realName != null && realName.isNotEmpty) { + _originalDeviceName = realName; + try { + await _meshCoreConnection!.setAdvertName('Anonymous'); + _isAnonymousRenamed = true; + _displayDeviceName = 'Anonymous'; + debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + // Short delay for firmware to process + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + debugError('[CONN] Anonymous mode: rename failed: $e'); + // Continue with real name if rename fails + } + } + } + + // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { debugError('[APP] Cannot request auth: could not retrieve device name'); return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; @@ -831,6 +1020,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Sync zone capacity display with auth result + _syncZoneCapacityFromAuth(result); + return result; } @@ -920,6 +1112,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Sync zone capacity display with auth result + _syncZoneCapacityFromAuth(registerResult); + return registerResult; }; } else { @@ -934,14 +1129,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); // Persist device info for bug reports when disconnected - // Use companion name (selfInfo.name) or BLE device name with MeshCore- prefix stripped - var deviceName = _meshCoreConnection!.selfInfo?.name ?? - connectedDeviceName; + // Use original name (not "Anonymous") for bug report identification + var deviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); if (deviceName != null) { // Always strip MeshCore- prefix if present deviceName = deviceName.replaceFirst('MeshCore-', ''); @@ -949,6 +1146,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } + + // In offline mode, fetch signed contact URI for later registration during upload + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } } notifyListeners(); }); @@ -981,8 +1188,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { powerLevel: device.power, txPower: device.txPower, autoPowerSet: true, // Indicates power was auto-detected from device model + powerLevelSet: false, // Clear stale manual flag from previous session ); - // TODO: Persist to SharedPreferences when implemented notifyListeners(); debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } @@ -1010,12 +1217,54 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - final newValidator = PacketValidator(allowedChannels: allowedChannels); + final newValidator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); _unifiedRxHandler!.updateValidator(newValidator); debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } + // Set flood scope from API response (regional TX filtering) + // "*" or "#*" = wildcard/global → no scope (unscoped flood, same as before) + // Any other value (e.g., "ottawa") → derive TransportKey and set scope + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[CONN] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); + debugLog('[CONN] Flood scope set successfully'); + } else { + _scope = null; + debugLog('[CONN] No regional scope — using unscoped flood'); + } + + // Enforce hybrid mode if required by regional admin + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[CONN] Hybrid mode force-enabled by regional admin'); + } + + // Enforce discovery drop if required by regional admin + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[CONN] Discovery drop force-enabled by regional admin'); + } + + // Enforce minimum auto-ping interval if required by regional admin + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); + debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + } + + // Configure multi-byte path hash mode on radio + await _configurePathHashMode(); + // Create ping service with wakelock (create new instance per connection) _pingService = PingService( gpsService: _gpsService, @@ -1029,19 +1278,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceId: _deviceId, txTracker: _txTracker, audioService: _audioService, + disableRssiFilter: _preferences.disableRssiFilter, + hopBytes: effectiveHopBytes, + traceHopBytes: _traceHopBytes, shouldIgnoreRepeater: (String repeaterId) { - // Same filter as RxLogger - check user preferences for ignored repeater ID final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - // Case-insensitive comparison (both uppercase) - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - final current = repeaterId.toUpperCase(); - return current == ignored; + return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); } return false; }, ); + // Wire UnifiedRxHandler so trace payloads route to TraceTracker + _pingService!.unifiedRxHandler = _unifiedRxHandler; + // Set validation callbacks _pingService!.checkExternalAntennaConfigured = () { // External antenna must be explicitly set (yes or no) before pinging @@ -1062,6 +1313,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check if TX is allowed by API (zone capacity) _pingService!.checkTxAllowed = () => txAllowed; + // Check if discovery drop is enabled + _pingService!.getDiscDropEnabled = () => discDropEnabled; + _pingService!.onTxPing = (ping) { _txPings.add(ping); if (_txPings.length > _maxMapPins) _txPings.removeAt(0); @@ -1096,6 +1350,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); + // Update RX overlay slot with this RX observation + _updateRxOverlaySlot(ping.repeaterId, ping.snr); + notifyListeners(); }; @@ -1112,7 +1369,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; + : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1125,7 +1383,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Handle real-time echo updates - update TxLogEntry as echoes are received _pingService!.onEchoReceived = (txPing, repeater, isNew) { debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr}, isNew: $isNew)'); + debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); // Find the matching TxLogEntry and update its events @@ -1165,6 +1423,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + + // Update top repeaters overlay with current TX echoes + _updateTopRepeaters(existingEvents + .where((e) => e.snr != null) + .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), OverlayPingType.tx); + debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); @@ -1182,6 +1447,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up auto ping scheduled callback for countdown display _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { _autoPingTimer.startWithSkipReason(intervalMs, skipReason); + + // Track idle time for auto-stop + if (skipReason != null) { + // Ping was skipped — check if idle too long + if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + final elapsed = DateTime.now().difference(_idleAutoStopReference!); + if (elapsed >= _autoStopIdleTimeout) { + _triggerIdleAutoStop(); + } + } + } else { + // Successful ping — reset idle reference + _idleAutoStopReference = DateTime.now(); + } }; // Wire up discovery ping callback - fires immediately (like onTxPing) @@ -1195,6 +1474,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (isNew) { _audioService.playReceiveSound(); } + + // Update top repeaters overlay with all discovered nodes from this ping + _updateTopRepeaters(discPing.discoveredNodes + .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), OverlayPingType.disc); + notifyListeners(); }; @@ -1212,8 +1497,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (lastTx.events.isNotEmpty) { repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( repeaterId: e.repeaterId, - snr: e.snr, - rssi: e.rssi, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, )).toList(); } } @@ -1248,8 +1533,64 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + PingEventType eventType; + if (success) { + eventType = PingEventType.discSuccess; + } else if (discDropEnabled) { + eventType = PingEventType.txFail; + } else { + eventType = PingEventType.discFail; + } + + recordPingEvent( + eventType, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; + + // Wire up trace ping callback (for log entry creation) + _pingService!.onTracePing = (entry) { + _addTraceLogEntry(entry); + }; + + // Wire up trace window complete callback for noise floor graph + _pingService!.onTraceWindowComplete = (result) { + double? lat; + double? lon; + List? repeaters; + + if (_traceLogEntries.isNotEmpty) { + final lastTrace = _traceLogEntries.first; + lat = lastTrace.latitude; + lon = lastTrace.longitude; + if (result != null && result.success) { + repeaters = [MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + )]; + // Update the log entry with success data + _traceLogEntries[0] = TraceLogEntry( + timestamp: lastTrace.timestamp, + latitude: lastTrace.latitude, + longitude: lastTrace.longitude, + targetRepeaterId: lastTrace.targetRepeaterId, + noiseFloor: lastTrace.noiseFloor, + localSnr: result.localSnr, + remoteSnr: result.remoteSnr, + localRssi: result.localRssi, + success: true, + ); + notifyListeners(); + } + } + recordPingEvent( - success ? PingEventType.discSuccess : PingEventType.discFail, + result != null && result.success + ? PingEventType.traceSuccess + : PingEventType.traceFail, latitude: lat, longitude: lon, repeaters: repeaters, @@ -1293,6 +1634,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update local state _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[APP] Pending disable cleanup complete, cooldown running'); notifyListeners(); @@ -1305,12 +1647,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // BLE advertisement name may be cached/stale after device rename final selfInfoName = _meshCoreConnection?.selfInfo?.name; if (selfInfoName != null && selfInfoName.isNotEmpty) { - _displayDeviceName = selfInfoName; - debugLog('[APP] Display name set from SelfInfo: "$selfInfoName"'); + // Keep "Anonymous" display name if anonymous mode is active + _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; + debugLog('[APP] Display name set: "$_displayDeviceName"'); + + // Update remembered device with real name (not "Anonymous") + // BLE advertisement name may be stale after device rename + final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; + if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { + final updatedName = 'MeshCore-$realName'; + if (_rememberedDevice!.name != updatedName) { + await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); + debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); + } + } } // Restore per-device antenna preference if previously saved - final resolvedName = displayDeviceName; + // Use original name for keying, not "Anonymous" + final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { final savedAntenna = _deviceAntennaPreferences[resolvedName]!; _preferences = _preferences.copyWith( @@ -1323,6 +1678,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + // Restore per-device power override if previously saved + if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { + final saved = _devicePowerOverrides[resolvedName]!; + _preferences = _preferences.copyWith( + powerLevel: (saved['powerLevel'] as num).toDouble(), + txPower: (saved['txPower'] as num).toInt(), + autoPowerSet: false, + powerLevelSet: true, + ); + _powerRestoredFromDevice = true; + _savePreferences(); + debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + notifyListeners(); + } + // Log connection status based on TX/RX permissions if (hasApiSession) { if (txAllowed && rxAllowed) { @@ -1332,6 +1702,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } else { debugLog('[CONN] Connected with limited access'); } + + // Start periodic zone refresh to keep slot counts current + if (!_preferences.offlineMode) { + _startZoneRefreshTimer(); + } } else { // No API session - offline mode or auth skipped debugLog('[CONN] Connected without API session (offline mode)'); @@ -1374,12 +1749,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { _connectionError = 'Authentication failed'; } } else { _isAuthError = false; + _isNetworkError = false; // Provide clean user-facing messages for common BLE errors if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; @@ -1398,6 +1775,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create TX tracker (stored for use by PingService) _txTracker = TxTracker(); + _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; + + // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) + _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1407,37 +1789,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; debugLog('[APP] TxTracker.onCarpeaterDrop callback SET'); - // Function to check if repeater should be ignored (carpeater filter) - _txTracker!.shouldIgnoreRepeater = (String repeaterId) { - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - return repeaterId.toUpperCase() == ignored; - } - return false; - }; - debugLog('[APP] TxTracker.shouldIgnoreRepeater callback SET'); - // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( - // Function to check if repeater should be ignored (carpeater filter) - shouldIgnoreRepeater: (String repeaterId) { - // Check user preferences for ignored repeater ID - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - // Case-insensitive comparison (both uppercase) - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - final current = repeaterId.toUpperCase(); - return current == ignored; - } - return false; - }, + // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) + carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' - 'snr=${observation.snr}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); + 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); @@ -1452,8 +1813,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: observation.lon, repeaterId: observation.repeaterId, timestamp: DateTime.now(), - snr: observation.snr, - rssi: observation.rssi, + snr: observation.snr ?? 0.0, + rssi: observation.rssi ?? 0, ); _rxPings.add(rxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -1465,6 +1826,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); + // Update RX overlay slot immediately + if (observation.snr != null) { + _updateRxOverlaySlot(repeaterKey, observation.snr!); + } // Play receive sound for new RX observation _audioService.playReceiveSound(); // Record RX event for noise floor graph with location and repeater info @@ -1475,8 +1840,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaters: [ MarkerRepeaterInfo( repeaterId: observation.repeaterId, - snr: observation.snr, - rssi: observation.rssi, + snr: observation.snr ?? 0.0, + rssi: observation.rssi ?? 0, ), ], ); @@ -1496,7 +1861,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' - 'snr=${entry.snr}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); + 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1513,20 +1878,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (lastPinIndex != -1) { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; - if (entry.snr > existingPin.snr) { + // Only update if new SNR is non-null and better (null never replaces non-null) + final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; + if (shouldUpdateSnr) { _rxPings[lastPinIndex] = RxPing( latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr, // UPDATE to best SNR from batch - rssi: entry.rssi, + snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch + rssi: entry.rssi ?? existingPin.rssi, ); debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' - '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr.toStringAsFixed(2)}'); + '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' - 'batch best ${entry.snr.toStringAsFixed(2)} <= pin ${existingPin.snr.toStringAsFixed(2)}'); + 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { // Edge case: pin not found (should have been created in onObservation) @@ -1535,8 +1902,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: entry.lon, repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr, - rssi: entry.rssi, + snr: entry.snr ?? 0.0, + rssi: entry.rssi ?? 0, ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -1566,13 +1933,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxLogEntries.add(rxLogEntry); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); debugLog('[APP] Added RX log entry: repeater=${entry.repeaterId}, ' - 'snr=${entry.snr}, pathLen=${entry.pathLength}'); + 'snr=${entry.snr ?? 'null'}, pathLen=${entry.pathLength}'); + + // Update RX overlay slot with this RX observation + if (entry.snr != null) { + _updateRxOverlaySlot(entry.repeaterId, entry.snr!); + } // Note: RX count is incremented in onObservation when pin is created (immediate feedback) // Enqueue to API with formatted heard_repeats string - // Format: "repeaterId(snr)" e.g. "4e(12.25)" - final heardRepeats = '${entry.repeaterId}(${entry.snr.toStringAsFixed(2)})'; + // Format: "repeaterId(snr)" e.g. "4e(12.25)" or "4e(null)" for CARpeater pass-through + final heardRepeats = entry.snr != null + ? '${entry.repeaterId}(${entry.snr!.toStringAsFixed(2)})' + : '${entry.repeaterId}(null)'; await _apiQueueService.enqueueRx( latitude: entry.lat, longitude: entry.lon, @@ -1617,8 +1991,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); - final validator = PacketValidator(allowedChannels: allowedChannels); - + final validator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); + // Create unified handler _unifiedRxHandler = UnifiedRxHandler( txTracker: _txTracker!, @@ -1639,19 +2016,174 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Full disconnect cleanup - called on normal BLE disconnect (user-requested or no remembered device) /// Extracted from the original BLE disconnect listener + /// Configure multi-byte path hash mode on the radio during connection + /// Reads device's current mode, determines effective mode, and sends command if needed + Future _configurePathHashMode() async { + final deviceInfo = _meshCoreConnection?.deviceInfo; + if (deviceInfo == null) return; + + // Store the device's current mode (from DeviceInfo response) + _originalPathHashMode = deviceInfo.pathHashMode; + + // Sync runtime hopBytes from device's current mode + if (_originalPathHashMode != null) { + final deviceHopBytes = _originalPathHashMode! + 1; + _hopBytes = deviceHopBytes; + // Map TX bytes to trace bytes (3-byte traces not possible, use 4) + _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); + } else { + _hopBytes = 1; + _traceHopBytes = 1; + } + + final effective = effectiveHopBytes; + final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceHopBytes = deviceMode + 1; + + if (effective != deviceHopBytes && _originalPathHashMode != null) { + // Need to change the radio's path hash mode + try { + await _meshCoreConnection!.setPathHashMode(effective - 1); + _hopBytes = effective; // Update runtime state to reflect new mode + _traceHopBytes = effective == 3 ? 4 : effective; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + + // Show warning popup if changing from 1-byte to multi-byte + if (deviceMode == 0 && effective > 1) { + final reason = enforceHopBytes + ? 'set by your regional admin' + : 'set in your app preferences'; + _pendingPathHashWarning = (hopBytes: effective, reason: reason); + notifyListeners(); // Trigger UI to show warning + } + } catch (e) { + debugError('[PATH] Failed to set path hash mode: $e'); + } + } else if (_originalPathHashMode == null && effective > 1) { + // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte + debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); + if (enforceHopBytes) { + _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); + notifyListeners(); + } + } else { + debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + } + } + + /// Restore radio to original path hash mode on clean disconnect + /// Skipped if the user manually changed the setting — they know what they're doing + Future _restorePathHashMode() async { + if (_originalPathHashMode == null) return; + + if (_userChangedPathMode) { + debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); + _originalPathHashMode = null; + _userChangedPathMode = false; + return; + } + + final originalMode = _originalPathHashMode!; + final originalHopBytes = originalMode + 1; + + // Compare current runtime mode against what the device had before we changed it + if (_hopBytes != originalHopBytes) { + try { + await _meshCoreConnection?.setPathHashMode(originalMode); + debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); + } catch (e) { + debugError('[PATH] Failed to restore path hash mode: $e'); + } + } else { + debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); + } + _originalPathHashMode = null; + _userChangedPathMode = false; + } + + /// Send path hash mode to radio immediately when user changes setting while connected + void _applyLivePathHashMode(int newHopBytes) { + if (_originalPathHashMode == null) { + // Old firmware — can't send command, show warning + debugWarn('[PATH] Cannot change path mode: firmware does not support it'); + _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); + _hopBytes = 1; // Force back to 1 + notifyListeners(); + return; + } + + _hopBytes = newHopBytes; + _userChangedPathMode = true; + _pingService?.hopBytes = newHopBytes; + // Auto-map trace bytes when TX bytes change (3→4, others stay same) + final oldTraceHopBytes = _traceHopBytes; + _traceHopBytes = newHopBytes == 3 ? 4 : newHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; + // Clear target repeater if trace bytes changed — old hex ID has wrong byte length + if (_traceHopBytes != oldTraceHopBytes) { + _targetRepeaterId = null; + } + final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 + _meshCoreConnection?.setPathHashMode(mode); + debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); + notifyListeners(); + } + + /// Set hop bytes (called from settings UI). Each companion device may differ. + void setHopBytes(int value) { + if (value < 1 || value > 3) return; + if (value == _hopBytes) return; + + if (isConnected) { + _applyLivePathHashMode(value); + } else { + _hopBytes = value; + notifyListeners(); + } + } + + /// Set trace hop bytes (called from settings UI). Valid values: 1, 2, 4. + void setTraceHopBytes(int value) { + if (value != 1 && value != 2 && value != 4) return; + if (value == _traceHopBytes) return; + _traceHopBytes = value; + _pingService?.traceHopBytes = value; + // Clear target repeater — old hex ID has wrong byte length + _targetRepeaterId = null; + debugLog('[TRACE] User changed trace bytes to $value'); + notifyListeners(); + } + + /// Pending path hash warning data (for UI to show dialog) + ({int hopBytes, String reason})? _pendingPathHashWarning; + ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; + + /// Clear the pending warning after UI has shown it + void clearPathHashWarning() { + _pendingPathHashWarning = null; + } + Future _fullDisconnectCleanup() async { + _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; // Stop heartbeat immediately on BLE disconnect _apiService.disableHeartbeat(); debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); + // Stop zone refresh timer + _stopZoneRefreshTimer(); + // Stop auto-ping timers _autoPingTimer.stop(); _rxWindowTimer.stop(); _cooldownTimer.stop(); if (_autoPingEnabled) { _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); } @@ -1688,6 +2220,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // Reset anonymous mode state (BLE already gone, can't restore name) + _isAnonymousRenamed = false; + _originalDeviceName = null; + + // Clear top-heard overlay + _clearOverlayState(); + // Existing cleanup _meshCoreConnection?.dispose(); _meshCoreConnection = null; @@ -1697,6 +2236,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { + _cancelPendingAutoPingRestore(); _isAutoReconnecting = true; _reconnectAttempt = 0; _connectionStep = ConnectionStep.reconnecting; @@ -1710,6 +2250,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); _autoPingEnabled = false; + _idleAutoStopReference = null; // Stop heartbeat _apiService.disableHeartbeat(); @@ -1818,9 +2359,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Restore auto-ping if it was active if (wasAutoPing) { + final restoreGeneration = _reconnectRestoreGeneration; // Use a short delay to ensure connection is fully set up - Timer(const Duration(milliseconds: 500), () { - if (_connectionStep == ConnectionStep.connected) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + restoreGeneration != _reconnectRestoreGeneration || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + return; + } + + if (!_autoPingEnabled) { toggleAutoPing(previousMode); debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } @@ -1843,17 +2396,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); // Clear reconnect state _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; - // Reset antenna setting so user must choose again on next connect + // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); + // Reset anonymous mode state (BLE already gone, can't restore name) + _isAnonymousRenamed = false; + _originalDeviceName = null; + // Do full disconnect cleanup (releases API session, etc.) _fullDisconnectCleanup(); notifyListeners(); @@ -1869,6 +2428,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; @@ -1876,10 +2436,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disable heartbeat immediately on disconnect _apiService.disableHeartbeat(); + // Stop zone refresh timer + _stopZoneRefreshTimer(); + // Stop auto-ping if running (before releasing session) if (_autoPingEnabled) { await _pingService?.forceDisableAutoPing(); _autoPingEnabled = false; + _idleAutoStopReference = null; } // End noise floor session on disconnect @@ -1896,6 +2460,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop RX wardriving if active (flushes batches to queue) _rxLogger?.stopWardriving(trigger: 'disconnect'); + // Save offline pings before clearing queue (no-op if not in offline mode or no pings) + await _saveOfflineSession(); + // ALWAYS START FRESH - clear any queued data on disconnect // Pings without a valid session cannot be uploaded later await _apiQueueService.clearOnDisconnect(); @@ -1915,9 +2482,37 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // Restore original device name if anonymous mode renamed it (BLE must still be connected) + if (_isAnonymousRenamed && _originalDeviceName != null) { + try { + await _meshCoreConnection?.setAdvertName(_originalDeviceName!); + debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + } catch (e) { + debugError('[CONN] Anonymous mode: failed to restore name: $e'); + logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', + severity: ErrorSeverity.warning, autoSwitch: false); + } + _isAnonymousRenamed = false; + _originalDeviceName = null; + } + + // Restore original path hash mode before disconnect (while BLE still connected) + await _restorePathHashMode(); + + // Clear flood scope before disconnect (safety — BLE disconnect resets radio state anyway) + try { + await _meshCoreConnection?.clearFloodScope(); + } catch (e) { + debugLog('[CONN] Failed to clear flood scope: $e'); + } + // Delete wardriving channel FIRST, while BLE connection is still active // This prevents "GATT Server is disconnected" errors - await _meshCoreConnection?.deleteWardrivingChannelEarly(); + if (_preferences.deleteChannelOnDisconnect) { + await _meshCoreConnection?.deleteWardrivingChannelEarly(); + } else { + debugLog('[CHANNEL] Skipping channel deletion (user preference)'); + } // Cleanup unified RX handler and TX tracker _logRxDataSubscription?.cancel(); @@ -1944,18 +2539,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _connectionStep = ConnectionStep.disconnected; _deviceModel = null; _manufacturerString = null; + _firmwareVersionString = null; _devicePublicKey = null; + _offlineContactUri = null; _displayDeviceName = null; _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; _authType = null; + _originalPathHashMode = null; + _userChangedPathMode = false; + _hopBytes = 1; + _traceHopBytes = 1; - // Clear regional channels (keeps only Public) + // Clear regional channels (keeps only Public) and scope ChannelService.clearRegionalChannels(); _regionalChannels = []; + _scope = null; // Clear discovered devices so user must scan fresh _discoveredDevices = []; @@ -2036,14 +2639,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return true; } - /// Toggle auto-ping mode (Active, Passive, or Hybrid) - /// Returns false if blocked by cooldown (Active/Hybrid Mode only - Passive Mode ignores cooldown) + /// Set the target repeater ID for targeted mode + void setTargetRepeaterId(String? id) { + _targetRepeaterId = id; + notifyListeners(); + } + + /// Auto-stop auto-ping after prolonged idle (no movement) + void _triggerIdleAutoStop() { + if (!_autoPingEnabled) return; + final elapsed = _idleAutoStopReference != null + ? DateTime.now().difference(_idleAutoStopReference!).inMinutes + : 30; + debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); + logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); + _idleAutoStopReference = null; + toggleAutoPing(_autoMode); + } + + /// Toggle auto-ping mode (Active, Passive, Hybrid, or Trace) + /// Returns false if blocked by cooldown (Active/Hybrid/Trace Mode only - Passive Mode ignores cooldown) Future toggleAutoPing(AutoMode mode) async { if (_pingService == null) return false; final isPassive = mode == AutoMode.passive; final isHybrid = mode == AutoMode.hybrid; - final isTxMode = !isPassive; // Active and Hybrid both do TX + final isTargeted = mode == AutoMode.targeted; + final isTxMode = !isPassive; // Active, Hybrid, and Targeted all do TX // If currently running the same mode, stop it (always allow stopping) if (_autoPingEnabled && _autoMode == mode) { @@ -2088,12 +2710,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _apiService.disableHeartbeat(); _autoPingEnabled = false; + _idleAutoStopReference = null; - // Start 7-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode + // Clear top-heard overlay on stop + _clearOverlayState(); + + // Start 5-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode // Passive Mode is listening only, no cooldown needed if (isTxMode) { - _cooldownTimer.start(7000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (7s) - blocks TX Ping and TX modes'); + _cooldownTimer.start(5000); + debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } @@ -2121,6 +2747,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop countdown timers when switching modes _autoPingTimer.stop(); _rxWindowTimer.stop(); + // Clear top-heard overlay on mode switch + _clearOverlayState(); // Save offline session if offline mode is enabled if (_preferences.offlineMode) { await _saveOfflineSession(); @@ -2138,7 +2766,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.setAutoPingInterval(intervalMs); debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); - final started = await _pingService!.enableAutoPing(passiveMode: isPassive, hybridMode: isHybrid); + final started = await _pingService!.enableAutoPing( + passiveMode: isPassive, + hybridMode: isHybrid, + targetedMode: isTargeted, + targetRepeaterId: isTargeted ? _targetRepeaterId : null, + ); if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { @@ -2152,9 +2785,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reference: state.rxTracking.isWardriving = true in wardrive.js _rxLogger?.startWardriving(); _autoPingEnabled = true; + _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : 'active'; + final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -2174,7 +2808,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : 'Active Mode'; + final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -2191,6 +2825,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _clearOverlayState(); _pingService?.resetStats(); notifyListeners(); } @@ -2200,7 +2835,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txLogEntries.clear(); _rxLogEntries.clear(); _discLogEntries.clear(); + _traceLogEntries.clear(); _errorLogEntries.clear(); + _clearOverlayState(); notifyListeners(); } @@ -2214,6 +2851,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Add a trace log entry (from Trace Mode) + void _addTraceLogEntry(TraceLogEntry entry) { + _traceLogEntries.insert(0, entry); + if (_traceLogEntries.length > _maxLogEntries) { + _traceLogEntries.removeLast(); + } + debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + + // Update top repeaters overlay with successful trace result + if (entry.success && entry.localSnr != null) { + // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay + final id = entry.targetRepeaterId.toUpperCase(); + final displayId = id.length > 6 ? id.substring(0, 6) : id; + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); + } + + notifyListeners(); + } + /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { @@ -2282,6 +2938,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Offline mode ${enabled ? 'enabled' : 'disabled'}'); if (enabled) { + // Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); // Clear zone data when entering offline mode _inZone = null; _currentZone = null; @@ -2289,6 +2947,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _lastZoneCheckPosition = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); } else { + // Stop auto-save timer when leaving offline mode + _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); @@ -2341,6 +3001,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(offlineMode: true); _apiQueueService.offlineMode = true; + // 5b. Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); + // 6. Clear zone data _inZone = null; _currentZone = null; @@ -2375,8 +3038,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _saveOfflineSession(); // 4. Request new auth session - final deviceName = _meshCoreConnection?.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', ''); + // Use "Anonymous" if renamed, otherwise real name + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { debugError('[APP] Cannot switch to online mode: no device name available'); @@ -2396,33 +3062,117 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } - debugLog('[APP] Requesting auth for online mode'); - final result = await _apiService.requestAuth( + // ============================================================ + // STAGE 1: Try existing public_key authentication + // ============================================================ + debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + + final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; + + var result = await _apiService.requestAuth( reason: 'connect', publicKey: _devicePublicKey!, who: deviceName, appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + model: modelString, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, ); - if (result == null) { - debugError('[APP] Auth request failed: no response'); - _modeSwitchError = 'Unable to connect to server'; + // Check for maintenance mode + if (result != null && result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + _startMaintenancePolling(); + notifyListeners(); + _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } - if (result['success'] != true) { - final reason = result['reason'] as String?; - final message = result['message'] as String?; - debugError('[APP] Auth request failed: $reason - $message'); - _modeSwitchError = message ?? reason ?? 'Authentication failed'; + // Check if Stage 1 succeeded + if (result != null && result['success'] == true) { + debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + if (result['type'] != null) { + _authType = result['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(result); + } else if (result == null) { + // API unreachable (null = network/timeout error) + debugError('[APP] API unreachable - network error'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; return (success: false, error: _modeSwitchError); + } else { + // Stage 1 failed — check if Stage 2 is worth attempting + debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + + final stage1Reason = result['reason'] as String?; + if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { + debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + _modeSwitchError = result['message'] as String? ?? 'GPS error'; + return (success: false, error: _modeSwitchError); + } + + // ============================================================ + // STAGE 2: Auth failed, attempt registration via signed contact_uri + // ============================================================ + debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + + String? contactUri; + try { + debugLog('[APP] Requesting signed contact URI from device...'); + contactUri = await _meshCoreConnection!.exportContact(); + debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + } catch (e) { + debugError('[APP] Failed to get contact URI from device: $e'); + _modeSwitchError = 'Companion not found in backend and failed to register via API'; + return (success: false, error: _modeSwitchError); + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: modelString, + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + if (registerResult == null) { + debugError('[APP] Stage 2 failed: network error (API unreachable)'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; + return (success: false, error: _modeSwitchError); + } + + if (registerResult['success'] != true) { + final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverMessage = registerResult['message'] as String?; + debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + _modeSwitchError = serverMessage ?? 'Registration rejected by server'; + return (success: false, error: _modeSwitchError); + } + + // Registration successful + debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + if (registerResult['type'] != null) { + _authType = registerResult['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(registerResult); + + result = registerResult; } // 5. Auth successful - update state @@ -2496,6 +3246,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 9. Update state _autoPingEnabled = false; + _idleAutoStopReference = null; debugLog('[APP] Auto-ping mode stopped gracefully'); notifyListeners(); } @@ -2535,17 +3286,59 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } - // Include device info for auth during upload (same priority as online auth: SelfInfo name → BLE name) + // Include device info for auth during upload (use real name, not "Anonymous" — sessions upload later) // Note: Connection already validates device name exists, so this should never be null + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); await _offlineSessionService.saveSession( pings, devicePublicKey: _devicePublicKey, - deviceName: _meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', ''), + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, ); + _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); + _stopOfflineAutoSaveTimer(); notifyListeners(); } + /// Periodically auto-save offline pings to prevent data loss from app kill. + /// Uses a non-destructive snapshot so in-memory accumulation continues. + void _autoSaveOfflinePings() { + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; + + final pings = _apiQueueService.getOfflinePingsSnapshot(); + if (pings.isEmpty) return; + + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + + _offlineSessionService.updateCurrentSession( + pings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + ); + } + + void _startOfflineAutoSaveTimer() { + _offlineAutoSaveTimer?.cancel(); + _offlineAutoSaveTimer = Timer.periodic(const Duration(seconds: 60), (_) { + _autoSaveOfflinePings(); + }); + debugLog('[OFFLINE] Auto-save timer started (60s interval)'); + } + + void _stopOfflineAutoSaveTimer() { + if (_offlineAutoSaveTimer != null) { + _offlineAutoSaveTimer!.cancel(); + _offlineAutoSaveTimer = null; + debugLog('[OFFLINE] Auto-save timer stopped'); + } + } + /// Upload a stored offline session Future uploadOfflineSession(String filename) async { final sessionData = _offlineSessionService.getSessionData(filename); @@ -2583,14 +3376,42 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Upload an offline session with authenticated API session - /// Uses stored device credentials to authenticate before uploading + /// Uses stored device credentials to authenticate before uploading. + /// Session is fully isolated from the shared ApiService state — offline uploads + /// never touch _sessionId and cannot trigger BLE disconnect on failure. /// + /// @param onProgress Optional callback for progress updates (e.g., "Batch 1/3") /// Returns the result of the upload operation - Future uploadOfflineSessionWithAuth(String filename) async { + Future uploadOfflineSessionWithAuth( + String filename, { + void Function(String status)? onProgress, + }) async { + // Concurrency guard — only one offline upload at a time + if (_isUploadingOfflineSession) { + debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); + return OfflineUploadResult.uploadInProgress; + } + + _isUploadingOfflineSession = true; + notifyListeners(); + + try { + return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); + } finally { + _isUploadingOfflineSession = false; + notifyListeners(); + } + } + + /// Internal implementation of offline session upload with isolated session + Future _uploadOfflineSessionIsolated( + String filename, { + void Function(String status)? onProgress, + }) async { // 1. Get session with stored device credentials final session = _offlineSessionService.getSession(filename); if (session == null) { - debugLog('[APP] Offline session not found: $filename'); + debugLog('[OFFLINE] Session not found: $filename'); return OfflineUploadResult.notFound; } @@ -2601,25 +3422,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList(); if (pings == null || pings.isEmpty) { - debugLog('[APP] Offline session has no pings: $filename'); + debugLog('[OFFLINE] Session has no pings: $filename'); return OfflineUploadResult.invalidSession; } // 2. Get device credentials from session final publicKey = session.devicePublicKey; if (publicKey == null) { - debugLog('[APP] Offline session missing device public key: $filename'); + debugLog('[OFFLINE] Session missing device public key: $filename'); return OfflineUploadResult.invalidSession; } final deviceName = session.deviceName; if (deviceName == null || deviceName.isEmpty) { - debugLog('[APP] Offline session missing device name: $filename'); + debugLog('[OFFLINE] Session missing device name: $filename'); return OfflineUploadResult.invalidSession; } - // 3. Authenticate with offline_mode: true - debugLog('[APP] Authenticating for offline upload with device: $deviceName'); + onProgress?.call('Authenticating...'); + + // 3. Authenticate with offline_mode: true, skipSessionStore: true + // This prevents writing to shared _sessionId/_txAllowed/etc. + debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -2632,54 +3456,105 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, offlineMode: true, + skipSessionStore: true, ); - if (authResult == null || authResult['success'] != true) { - final reason = authResult?['reason'] as String? ?? 'unknown'; - debugError('[API] Offline upload auth failed: $reason'); + Map? effectiveAuth = authResult; + + if (authResult == null) { + debugError('[OFFLINE] Auth failed: network error'); return OfflineUploadResult.authFailed; } - debugLog('[APP] Offline upload authenticated, session: ${authResult['session_id']}'); + if (authResult['success'] != true) { + final reason = authResult['reason'] as String? ?? 'unknown'; + debugLog('[OFFLINE] Stage 1 failed: $reason'); + + // Stage 2: If unknown_device and we have a stored contactUri, attempt registration + if (reason == 'unknown_device' && session.contactUri != null) { + debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: session.contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: 'Offline Upload', + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + offlineMode: true, + skipSessionStore: true, + ); + + if (registerResult == null || registerResult['success'] != true) { + final regReason = registerResult?['reason'] as String? ?? 'unknown'; + debugError('[OFFLINE] Stage 2 registration failed: $regReason'); + return OfflineUploadResult.authFailed; + } + + debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); + effectiveAuth = registerResult; + } else { + debugError('[OFFLINE] Auth failed: $reason'); + return OfflineUploadResult.authFailed; + } + } + + // Extract session_id into local variable — never stored in shared state + final offlineSessionId = effectiveAuth!['session_id'] as String?; + if (offlineSessionId == null) { + debugError('[OFFLINE] Auth succeeded but no session_id in response'); + return OfflineUploadResult.authFailed; + } + + debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); - // 4. Upload pings in batches of 50 + // 4. Upload pings in batches of 50 using isolated session const batchSize = 50; var uploadedCount = 0; var failedBatches = 0; + final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; for (var i = 0; i < pings.length; i += batchSize) { + final batchNum = (i ~/ batchSize) + 1; + onProgress?.call('Batch $batchNum/$totalBatches'); + final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatch(batch); + final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); if (result == UploadResult.success) { uploadedCount += batch.length; - debugLog('[APP] Uploaded batch ${(i ~/ batchSize) + 1}: ${batch.length} pings'); + debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); } else { failedBatches++; - debugError('[APP] Failed to upload batch ${(i ~/ batchSize) + 1}'); + debugError('[OFFLINE] Failed to upload batch $batchNum'); } } // Delay after posting before disconnect await Future.delayed(const Duration(seconds: 1)); - // 5. Release API session + // 5. Release isolated API session (does not clear shared state) + onProgress?.call('Finalizing...'); await _apiService.requestAuth( reason: 'disconnect', publicKey: publicKey, + sessionId: offlineSessionId, ); - debugLog('[APP] Offline upload session released'); + debugLog('[OFFLINE] Isolated upload session released'); // 6. Mark session as uploaded (don't delete) if all batches succeeded if (failedBatches == 0) { await _offlineSessionService.markAsUploaded(filename); - debugLog('[API] Uploaded ${pings.length} pings from $filename'); + debugLog('[OFFLINE] Uploaded ${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[API] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -2705,23 +3580,104 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void updatePreferences(UserPreferences preferences) { debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); + _preferences = preferences; - // Clear restored flag — user is making a manual choice now + // Clear restored flags — user is making a manual choice now _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; - // Persist antenna choice per device name - final deviceName = displayDeviceName; + // Persist antenna choice per device name (use original name, not "Anonymous") + final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } + // Persist power override per device name + if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { + _devicePowerOverrides[deviceName] = { + 'powerLevel': preferences.powerLevel, + 'txPower': preferences.txPower, + }; + _saveDevicePowerOverrides(); + debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + } else if (deviceName != null && preferences.autoPowerSet) { + // User re-selected the auto-detected value — clear any saved override + if (_devicePowerOverrides.remove(deviceName) != null) { + _saveDevicePowerOverrides(); + debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + } + } + + // Propagate RSSI filter setting to live trackers/validators + _syncRssiFilterSetting(preferences.disableRssiFilter); + + // Propagate CARpeater prefix to live trackers + _syncCarpeaterPrefix(); + + // Propagate min ping distance to GpsService and PingService + _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = preferences.minPingDistanceMeters; + notifyListeners(); _savePreferences(); } + /// Set anonymous mode, disconnecting and reconnecting if currently connected + Future setAnonymousMode(bool enabled) async { + if (enabled == _preferences.anonymousMode) return; + + _preferences = _preferences.copyWith(anonymousMode: enabled); + _savePreferences(); + notifyListeners(); + + // If connected, disconnect and reconnect for clean auth session + if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { + final deviceToReconnect = _bluetoothService.connectedDevice; + if (deviceToReconnect != null) { + _requestConnectionTabSwitch = true; + notifyListeners(); + await disconnect(); // Full cleanup (restores name if previously anonymous) + // Short delay for BLE cleanup + await Future.delayed(const Duration(milliseconds: 500)); + await connectToDevice(deviceToReconnect); + } + } + } + + /// Propagate carpeaterPrefix to live TxTracker and RxLogger + void _syncCarpeaterPrefix() { + final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + if (_txTracker != null) { + _txTracker!.carpeaterPrefix = prefix; + debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); + } + if (_rxLogger != null) { + _rxLogger!.carpeaterPrefix = prefix; + debugLog('[APP] Synced RxLogger.carpeaterPrefix = ${prefix ?? 'null'}'); + } + } + + /// Propagate disableRssiFilter to all active trackers and validators + void _syncRssiFilterSetting(bool disableRssiFilter) { + if (_txTracker != null) { + _txTracker!.disableRssiFilter = disableRssiFilter; + } + if (_unifiedRxHandler != null) { + final oldValidator = _unifiedRxHandler!.validator; + final newValidator = PacketValidator( + allowedChannels: oldValidator.allowedChannels, + disableRssiFilter: disableRssiFilter, + ); + _unifiedRxHandler!.updateValidator(newValidator); + } + if (_pingService != null) { + _pingService!.disableRssiFilter = disableRssiFilter; + } + } + /// Set developer mode (unlocked by tapping version 7 times) void setDeveloperMode(bool enabled) { _preferences = _preferences.copyWith(developerModeEnabled: enabled); @@ -2816,6 +3772,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _requestErrorLogSwitch = false; } + /// Clear the connection tab switch request (called by main scaffold after switching) + void clearConnectionTabSwitchRequest() { + _requestConnectionTabSwitch = false; + } + // ============================================ // API Error Handling // ============================================ @@ -2907,6 +3868,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { authErrors.contains(reason) || zoneErrors.contains(reason)) { debugLog('[API] Session error requires disconnect: $reason'); + + // Preserve queued wardrive data to offline storage before disconnect clears it + if (sessionErrors.contains(reason)) { + try { + final queuedPings = await _apiQueueService.extractAllAsJson(); + if (queuedPings.isNotEmpty) { + final offlineDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + await _offlineSessionService.saveSession( + queuedPings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + ); + debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + } + } catch (e) { + debugError('[APP] Failed to preserve queue to offline storage: $e'); + } + } + // Don't call requestAuth disconnect - session is already invalid on server // Just cleanup locally and disconnect await disconnect(); @@ -3115,6 +4099,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { + // Update position even on failure to prevent zone check flooding + // (without this, every GPS update re-triggers a zone check while driving) + _lastZoneCheckPosition = _currentPosition; debugError('[GEOFENCE] Zone status check failed: no response from API'); _scheduleZoneCheckRetry( seconds: 5, @@ -3153,11 +4140,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { - logError('GPS Accuracy Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_inaccurate'); + logError('GPS Accuracy Error\n$message', autoSwitch: false); + _zoneCheckError = message; + _zoneCheckErrorReason = 'gps_inaccurate'; + notifyListeners(); } else if (reason == 'gps_stale') { - logError('GPS Stale Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_stale'); + logError('GPS Stale Error\n$message', autoSwitch: false); + _zoneCheckError = message; + _zoneCheckErrorReason = 'gps_stale'; + notifyListeners(); } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); @@ -3209,6 +4200,68 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Sync zone capacity display with auth result. + /// The /status API (pre-connection) and /auth API (during connection) can + /// return different capacity views. This keeps the connection screen's slot + /// display consistent with the map tab's txAllowed flag. + void _syncZoneCapacityFromAuth(Map authResult) { + if (_currentZone == null) return; + + // If auth response includes slot data, use it directly (forward-compatible) + if (authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = authResult['slots_available']; + debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + } + if (authResult.containsKey('slots_max')) { + _currentZone!['slots_max'] = authResult['slots_max']; + debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + } + + // Sync at_capacity with tx_allowed + final authTxAllowed = authResult['tx_allowed'] == true; + _currentZone!['at_capacity'] = !authTxAllowed; + + // If auth says TX not allowed and server didn't provide slot data, set slots to 0 + if (!authTxAllowed && !authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = 0; + debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + } + + // If auth says TX allowed and we have slot data but server didn't provide updated count, + // decrement by 1 (we just took a slot) + if (authTxAllowed && !authResult.containsKey('slots_available')) { + final available = _currentZone!['slots_available'] as int?; + if (available != null && available > 0) { + _currentZone!['slots_available'] = available - 1; + debugLog('[CAPACITY] Took a slot, slots_available=${available - 1}'); + } + } + + notifyListeners(); + } + + /// Start periodic zone status refresh while connected. + /// Keeps slot counts and capacity status fresh during a session. + void _startZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = Timer.periodic(const Duration(seconds: 60), (_) async { + if (!isConnected || _preferences.offlineMode) { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + return; + } + debugLog('[CAPACITY] Periodic zone refresh'); + await checkZoneStatus(); + }); + debugLog('[CAPACITY] Started 60s zone refresh timer'); + } + + /// Stop zone status refresh timer. + void _stopZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { @@ -3235,21 +4288,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Debug File Logging (Mobile Only) // ============================================ - /// Auto-enable debug file logging for development builds - Future _autoEnableDebugLogsIfDevelopmentBuild() async { + /// Initialize debug file logging, respecting persisted user preference. + /// Enabled by default on all builds. If the user previously disabled it, + /// that preference is restored. + Future _initDebugLogs() async { if (kIsWeb) return; // File logging not available on web - if (AppConstants.isDevelopmentBuild) { - debugLog('[INIT] Development build detected (${AppConstants.appVersion}), auto-enabling debug logs'); - try { - await DebugFileLogger.enable(); + try { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) { + // Can't read preference — keep default (enabled, already started in main.dart) _debugLogsEnabled = true; await _refreshDebugLogFiles(); - } catch (e) { - debugError('[INIT] Failed to auto-enable debug logs: $e'); + return; } - } else { - debugLog('[INIT] Release build (${AppConstants.appVersion}), debug logs disabled by default'); + + final userDisabled = box.get('debug_logs_enabled') == false; + + if (userDisabled) { + debugLog('[INIT] Debug logs disabled by user preference, turning off'); + await DebugFileLogger.disable(); + _debugLogsEnabled = false; + DebugLogger.setEnabled(false); + } else { + debugLog('[INIT] Debug logging enabled (${AppConstants.appVersion})'); + // DebugFileLogger already enabled in main.dart + _debugLogsEnabled = true; + await _refreshDebugLogFiles(); + } + } catch (e) { + debugError('[INIT] Failed to init debug logs: $e'); + // Fallback: keep enabled (already started in main.dart) + _debugLogsEnabled = true; } } @@ -3266,6 +4336,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _debugLogsEnabled = true; DebugLogger.setEnabled(true); await _refreshDebugLogFiles(); + // Persist user preference + final box = await _openBoxSafely(_preferencesBoxName); + await box?.put('debug_logs_enabled', true); notifyListeners(); debugLog('[DEBUG] Debug file logging enabled'); } catch (e) { @@ -3285,6 +4358,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await DebugFileLogger.disable(); _debugLogsEnabled = false; DebugLogger.setEnabled(false); + // Persist user preference + final box = await _openBoxSafely(_preferencesBoxName); + await box?.put('debug_logs_enabled', false); notifyListeners(); } catch (e) { debugError('[DEBUG] Failed to disable debug file logging: $e'); @@ -3630,6 +4706,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); + + // Apply saved min ping distance to GpsService and PingService + _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = _preferences.minPingDistanceMeters; } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); @@ -3683,6 +4763,40 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // ============================================ + // Device Power Override Persistence + // ============================================ + + /// Load per-device power overrides from Hive storage + Future _loadDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final raw = box.get('device_power_overrides'); + if (raw != null) { + _devicePowerOverrides = (raw as Map).map( + (key, value) => MapEntry(key.toString(), Map.from(value as Map)), + ); + debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + } + } catch (e) { + debugLog('[APP] Failed to load device power overrides: $e'); + } + } + + /// Save per-device power overrides to Hive storage + Future _saveDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + await box.put('device_power_overrides', _devicePowerOverrides); + } catch (e) { + debugLog('[APP] Failed to save device power overrides: $e'); + } + } + // ============================================ // Last Connected Device Persistence // ============================================ @@ -3945,6 +5059,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Cleanup // ============================================ + void _cancelPendingAutoPingRestore() { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = null; + _reconnectRestoreGeneration++; + } + @override @override void notifyListeners() { @@ -3956,6 +5076,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isDisposed = true; WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); + _connectionSubscription?.cancel(); + _gpsStatusSubscription?.cancel(); + _gpsPositionSubscription?.cancel(); _logRxDataSubscription?.cancel(); _noiseFloorSubscription?.cancel(); _batterySubscription?.cancel(); @@ -3964,6 +5087,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneCheckCountdownTimer?.cancel(); _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); + _restoreAutoPingTimer?.cancel(); + _offlineAutoSaveTimer?.cancel(); + _zoneRefreshTimer?.cancel(); + _tileRefreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index e186fe3..ce97f87 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -13,6 +13,7 @@ import '../models/user_preferences.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../widgets/offline_mode_toggle.dart'; import '../widgets/regional_config_card.dart'; /// BLE device selection and connection screen @@ -74,84 +75,50 @@ class _ConnectionScreenState extends State with WidgetsBinding @override Widget build(BuildContext context) { final appState = context.watch(); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; - - // Build FAB for scanning - show Cancel during scan, Scan when idle - // Hide FAB entirely during maintenance mode (maintenance UI has its own buttons) - // Hide FAB when Bluetooth is off (shows full-screen message instead) - // Hide FAB during auto-reconnect - Widget? fab; - if (appState.isScanning) { - // Show Cancel FAB during active scan - fab = isLandscape - ? FloatingActionButton.small( - onPressed: () => appState.stopScan(), - backgroundColor: Colors.red, - child: const Icon(Icons.close), - ) - : FloatingActionButton.extended( - onPressed: () => appState.stopScan(), - icon: const Icon(Icons.close), - label: const Text('Cancel'), - backgroundColor: Colors.red, - ); - } else if (appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - !appState.isBluetoothOff) { - // Offline mode bypasses both zone and maintenance checks - final canScan = appState.offlineMode || appState.inZone == true; - fab = isLandscape - ? FloatingActionButton.small( - onPressed: canScan ? () => appState.startScan() : null, - backgroundColor: canScan ? null : Colors.grey, - child: const Icon(Icons.bluetooth_searching), - ) - : FloatingActionButton.extended( - onPressed: canScan ? () => appState.startScan() : null, - icon: const Icon(Icons.bluetooth_searching), - label: Text(appState.offlineMode - ? 'Scan' - : appState.gpsStatus == GpsStatus.disabled - ? 'GPS Disabled' - : appState.gpsStatus == GpsStatus.permissionDenied - ? 'GPS Required' - : appState.isCheckingZone - ? 'Checking Zone...' - : appState.inZone == true - ? 'Scan' - : appState.inZone == false - ? 'Outside Zone' - : 'Checking Zone...'), - backgroundColor: canScan ? null : Colors.grey, - ); - } return Scaffold( appBar: AppBar( - title: const Text('Connection'), + toolbarHeight: 40, + title: const Text('Connection', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, ), body: _buildBody(context, appState), - floatingActionButton: fab, ); } Widget _buildBody(BuildContext context, AppStateProvider appState) { - // Show reconnecting UI + // Reconnecting and connection progress don't show the zone bar or bottom bar if (appState.connectionStep == ConnectionStep.reconnecting) { return _buildReconnectingView(context, appState); } - - // Show connection progress if (appState.connectionStep != ConnectionStep.disconnected && appState.connectionStep != ConnectionStep.connected && appState.connectionStep != ConnectionStep.error) { return _buildConnectionProgress(context, appState); } + // All other states: zone bar top, content middle, action bar bottom + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded(child: _buildStateContent(context, appState)), + _buildBottomBar(context, appState), + ], + ); + } + + /// Routes to the correct sub-view (zone bar is already rendered above) + Widget _buildStateContent(BuildContext context, AppStateProvider appState) { // Show connected state if (appState.isConnected) { + final pathWarning = appState.pendingPathHashWarning; + if (pathWarning != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showPathHashWarning(context, pathWarning.hopBytes, pathWarning.reason); + appState.clearPathHashWarning(); + }); + } return _buildConnectedInfo(context, appState); } @@ -164,6 +131,99 @@ class _ConnectionScreenState extends State with WidgetsBinding return _buildDeviceList(context, appState); } + /// Persistent bottom action bar: offline toggle + scan/cancel/disconnect + Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 12), + Expanded(child: _buildActionButton(context, appState)), + ], + ), + ); + } + + /// The right-side action button: Scan, Cancel, or Disconnect + Widget _buildActionButton(BuildContext context, AppStateProvider appState) { + if (appState.isConnected) { + // Disconnect + return _buildBottomButton( + icon: Icons.bluetooth_disabled, + label: 'Disconnect', + color: Colors.red, + onPressed: () async => await appState.disconnect(), + ); + } + + if (appState.isScanning) { + // Cancel scan + return _buildBottomButton( + icon: Icons.close, + label: 'Cancel', + color: Colors.red, + onPressed: () => appState.stopScan(), + ); + } + + // Scan — disabled when can't scan + final canScan = appState.connectionStep == ConnectionStep.disconnected && + !appState.isAutoReconnecting && + (!appState.maintenanceMode || appState.offlineMode) && + !appState.isBluetoothOff && + (appState.offlineMode || appState.inZone == true); + + return _buildBottomButton( + icon: Icons.bluetooth_searching, + label: 'Scan', + color: Theme.of(context).colorScheme.primary, + onPressed: canScan ? () => appState.startScan() : null, + ); + } + + /// Styled button matching OfflineModeToggle shape + Widget _buildBottomButton({ + required IconData icon, + required String label, + required Color color, + VoidCallback? onPressed, + }) { + final enabled = onPressed != null; + final effectiveColor = enabled ? color : Colors.grey; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: effectiveColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: effectiveColor), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: effectiveColor, + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildConnectionProgress(BuildContext context, AppStateProvider appState) { final step = appState.connectionStep; final totalSteps = ConnectionStepExtension.totalSteps; @@ -248,132 +308,129 @@ class _ConnectionScreenState extends State with WidgetsBinding // Get device name - uses displayDeviceName which prefers SelfInfo name over BLE advertisement name final deviceName = appState.displayDeviceName ?? 'Unknown'; - // Parse version from manufacturer string and use shortName from device model for hardware - // Format examples: - // - "MeshCore (Heltec V3) v1.10.0" - // - "Ikoka Stick-E22-30dBm (Xiao_nrf52) nightly-e31c46f" + // Extract version from firmware version string (v7+), fall back to manufacturer string String? version; - final manufacturerString = appState.manufacturerString; - if (manufacturerString != null) { - // Use regex to find version pattern directly instead of splitting - // Match: v followed by digits/dots, OR nightly- followed by hex, OR just digits.digits - final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); - final match = versionRegex.firstMatch(manufacturerString); - if (match != null) { - version = match.group(1); + final fwString = appState.firmwareVersionString; + if (fwString != null && fwString.isNotEmpty) { + final semverMatch = RegExp(r'(\d+\.\d+\.\d+)').firstMatch(fwString); + if (semverMatch != null) { + version = semverMatch.group(1); + } else { + final nightlyMatch = RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); + if (nightlyMatch != null) { + version = nightlyMatch.group(1); + } + } + } + if (version == null) { + final manufacturerString = appState.manufacturerString; + if (manufacturerString != null) { + final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); + final match = versionRegex.firstMatch(manufacturerString); + if (match != null) { + version = match.group(1); + } } } - // Use shortName from device model if available, otherwise fall back to manufacturer string - final hardware = appState.deviceModel?.shortName ?? manufacturerString ?? 'Unknown'; - + final hardware = appState.deviceModel?.shortName ?? appState.manufacturerString ?? 'Unknown'; + final platform = appState.deviceModel?.platform; final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final prefs = appState.preferences; + final isAutoMode = appState.autoPingEnabled; + final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - // Build device info card - final deviceInfoCard = Card( + // Compact device summary card + final deviceSummaryCard = Card( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // Header: BT icon + name/status Row( children: [ - const Icon( - Icons.bluetooth_connected, - color: Colors.green, - size: 28, - ), - const SizedBox(width: 12), + const Icon(Icons.bluetooth_connected, color: Colors.green, size: 20), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Connected', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.green, - fontWeight: FontWeight.bold, - ), + deviceName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, ), Text( - deviceName, - style: Theme.of(context).textTheme.bodyLarge, + 'Connected', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.green, + ), ), ], ), ), ], ), - const Divider(height: 20), - _buildInfoRow('Hardware', hardware), - _buildInfoRow('Version', version ?? 'Unknown'), - if (appState.deviceModel != null) - _buildInfoRow('Platform', appState.deviceModel!.platform), - if (appState.devicePublicKey != null) + const SizedBox(height: 8), + + // Detail chips: hardware, version, platform + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _buildDetailChip(context, Icons.memory, hardware), + if (version != null) _buildDetailChip(context, Icons.code, version), + if (platform != null) _buildDetailChip(context, Icons.developer_board, platform), + ], + ), + + // Power level row + const SizedBox(height: 4), + _buildPowerRow(context, appState, isPowerSet, isAutoMode, prefs), + + // Public key row + if (appState.devicePublicKey != null) ...[ + const SizedBox(height: 8), _buildPublicKeyRow(context, appState.devicePublicKey!), - if (appState.authType != null && !appState.offlineMode) + ], + + // Registered via row + if (appState.authType != null && !appState.offlineMode) ...[ + const SizedBox(height: 4), _buildAuthTypeRow(context, appState.authType!), + ], ], ), ), ); - // Build disconnect button - final disconnectButton = ElevatedButton.icon( - onPressed: () async { - await appState.disconnect(); - }, - icon: const Icon(Icons.bluetooth_disabled), - label: const Text('Disconnect'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), + // Compact channels card + final channelsCard = RegionalConfigCard( + zoneName: appState.offlineMode ? null : appState.zoneName, + zoneCode: appState.offlineMode ? null : appState.zoneCode, + channels: appState.offlineMode ? [] : appState.regionalChannels, + scope: appState.offlineMode ? null : appState.scope, + isOfflineMode: appState.offlineMode, + compact: true, ); if (isLandscape) { - // Landscape: two-column layout return SafeArea( child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Left column: device info + disconnect Expanded( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: deviceInfoCard, - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ], - ), + child: SingleChildScrollView(child: deviceSummaryCard), ), const SizedBox(width: 12), - // Right column: power + regional config Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - _buildPowerLevelCard(context, appState), - const SizedBox(height: 12), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - isOfflineMode: appState.offlineMode, - ), - ], - ), - ), + child: SingleChildScrollView(child: channelsCard), ), ], ), @@ -381,55 +438,122 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - // Portrait: vertical layout - return Column( - children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.all(16), + // Portrait: compact vertical layout (bottom bar provided by _buildBody) + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + deviceSummaryCard, + const SizedBox(height: 12), + channelsCard, + ], + ), + ); + } + + /// Power level row matching Registered via / Public Key format + Widget _buildPowerRow( + BuildContext context, + AppStateProvider appState, + bool isPowerSet, + bool isAutoMode, + UserPreferences prefs, + ) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: InkWell( + onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( children: [ - deviceInfoCard, - const SizedBox(height: 16), - _buildPowerLevelCard(context, appState), - const SizedBox(height: 16), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - isOfflineMode: appState.offlineMode, + const SizedBox( + width: 120, + child: Text( + 'Power Level', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt, size: 16, color: isPowerSet ? Colors.amber.shade700 : Colors.orange), + const SizedBox(width: 4), + Text( + isPowerSet ? prefs.powerLevelDisplay : 'Unknown - tap to set', + style: TextStyle( + fontWeight: FontWeight.w500, + color: isPowerSet ? null : Colors.orange, + ), + ), + if (prefs.autoPowerSet) ...[ + const SizedBox(width: 4), + const Icon(Icons.auto_awesome, size: 14, color: Colors.green), + const SizedBox(width: 2), + const Text( + 'Auto', + style: TextStyle( + fontSize: 12, + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ] else if (prefs.powerLevelSet && !prefs.autoPowerSet && appState.deviceModel != null) ...[ + const SizedBox(width: 4), + const Icon(Icons.edit, size: 14, color: Colors.orange), + const SizedBox(width: 2), + const Text( + 'Override', + style: TextStyle( + fontSize: 12, + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ], + if (!isAutoMode) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 16, color: Colors.grey), + ], + ], ), ], ), ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ), - ], + ), ); } - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + /// Small detail chip with icon + text + Widget _buildDetailChip(BuildContext context, IconData icon, String text) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 120, - child: Text( - label, - style: const TextStyle(fontWeight: FontWeight.w500), + Icon(icon, size: 12, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, ), ), - Expanded(child: Text(value)), ], ), ); } + + Widget _buildPublicKeyRow(BuildContext context, String publicKey) { // Show truncated key for display (first 8 + ... + last 8) final displayKey = publicKey.length > 16 @@ -721,50 +845,6 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildPowerLevelCard(BuildContext context, AppStateProvider appState) { - final prefs = appState.preferences; - final isAutoMode = appState.autoPingEnabled; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - - return Card( - child: ListTile( - leading: const Icon(Icons.power), - title: const Text('Power Level'), - subtitle: Builder( - builder: (context) { - if (!isPowerSet) { - return Text( - 'Unknown hardware - select power', - style: TextStyle(color: Colors.orange.shade700), - ); - } - return Row( - children: [ - Text(prefs.powerLevelDisplay), - if (prefs.autoPowerSet) ...[ - const SizedBox(width: 8), - const Icon(Icons.auto_awesome, size: 14, color: Colors.green), - const SizedBox(width: 4), - const Text( - 'Auto', - style: TextStyle( - fontSize: 12, - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ); - }, - ), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), - ), - ); - } - void _showPowerLevelSelector(BuildContext context, AppStateProvider appState) { final prefs = appState.preferences; final deviceModel = appState.deviceModel; @@ -799,6 +879,7 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, // Clear auto flag on override + powerLevelSet: true, // Mark as manually overridden ), ); Navigator.pop(context); // Close confirmation @@ -831,23 +912,29 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Auto-detection info banner - if (prefs.autoPowerSet && deviceModel != null) + // Auto-detection / override info banner + if (deviceModel != null) Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - border: Border.all(color: Colors.green), + color: (prefs.autoPowerSet ? Colors.green : Colors.orange).withValues(alpha: 0.1), + border: Border.all(color: prefs.autoPowerSet ? Colors.green : Colors.orange), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - const Icon(Icons.auto_awesome, size: 20, color: Colors.green), + Icon( + prefs.autoPowerSet ? Icons.auto_awesome : Icons.edit, + size: 20, + color: prefs.autoPowerSet ? Colors.green : Colors.orange, + ), const SizedBox(width: 8), Expanded( child: Text( - 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W', + prefs.autoPowerSet + ? 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W' + : 'Override active \u2014 ${deviceModel.shortName} auto-detects as ${deviceModel.power}W', style: const TextStyle(fontSize: 12), ), ), @@ -867,7 +954,7 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: PowerLevel.values.map((power) { final isSelected = power == currentPower; - final isRecommended = prefs.autoPowerSet && deviceModel != null && power == deviceModel.power; + final isRecommended = deviceModel != null && power == deviceModel.power; // Create a temp preferences object to get the display string with dBm final tempPrefs = UserPreferences(powerLevel: power); @@ -914,6 +1001,21 @@ class _ConnectionScreenState extends State with WidgetsBinding ], ), actions: [ + if (!prefs.autoPowerSet && deviceModel != null) + TextButton( + onPressed: () { + appState.updatePreferences( + prefs.copyWith( + powerLevel: deviceModel.power, + txPower: deviceModel.txPower, + autoPowerSet: true, + powerLevelSet: false, + ), + ); + Navigator.pop(context); + }, + child: const Text('Reset to Auto', style: TextStyle(color: Colors.green)), + ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), @@ -934,18 +1036,22 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.error_outline, + appState.isNetworkError ? Icons.cloud_off : Icons.error_outline, size: isLandscape ? 48 : 64, - color: Colors.red, + color: appState.isNetworkError ? Colors.orange : Colors.red, ), SizedBox(height: isLandscape ? 8 : 16), Text( - appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', + appState.isNetworkError + ? 'Server Unreachable' + : appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - appState.connectionError ?? 'Unknown error', + appState.isNetworkError + ? 'MeshMapper services unreachable, try again or use offline mode.' + : appState.connectionError ?? 'Unknown error', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), @@ -982,6 +1088,17 @@ class _ConnectionScreenState extends State with WidgetsBinding locationIcon = Icons.engineering; locationText = 'Maintenance'; locationColor = Colors.orange; + // Network error: show wifi off indicator + } else if (appState.zoneCheckErrorReason == 'network') { + locationIcon = Icons.wifi_off; + locationText = 'No Internet'; + locationColor = Colors.red; + // GPS error: show GPS issue indicator + } else if (appState.zoneCheckErrorReason == 'gps_inaccurate' || + appState.zoneCheckErrorReason == 'gps_stale') { + locationIcon = Icons.gps_off; + locationText = 'GPS Unavailable'; + locationColor = Colors.orange; // Show "Checking Zone..." whenever a zone check is in progress // This provides consistent UI feedback during both initial and re-checks } else if (appState.isCheckingZone) { @@ -1152,103 +1269,89 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show maintenance message (takes priority over zone checks) if (appState.maintenanceMode && !appState.offlineMode) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.engineering, + size: 64, + color: Colors.orange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'Maintenance Mode', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + appState.maintenanceMessage ?? 'Service is temporarily unavailable.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 32), + // Primary action: Enable Offline Mode + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + if (appState.maintenanceUrl != null) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('More Info'), + ), + ], + const SizedBox(height: 32), + // Info note at bottom + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.engineering, - size: 64, - color: Colors.orange.withValues(alpha: 0.7), - ), - const SizedBox(height: 16), - const Text( - 'Maintenance Mode', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - appState.maintenanceMessage ?? 'Service is temporarily unavailable.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 32), - // Primary action: Enable Offline Mode - FilledButton.icon( - onPressed: () => appState.setOfflineMode(true), - icon: const Icon(Icons.cloud_off), - label: const Text('Enable Offline Mode'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - if (appState.maintenanceUrl != null) ...[ - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('More Info'), - ), - ], - const SizedBox(height: 32), - // Info note at bottom - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), - ), - ), - ], + Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle(fontSize: 13, color: Colors.blue.shade700), ), ), ], ), ), - ), + ], ), - ], + ), ); } // Show Bluetooth off message (takes priority over zone checks) if (appState.isBluetoothOff) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_disabled, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Bluetooth is Off', - message: 'Please enable Bluetooth to scan for MeshCore devices.', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_disabled, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Bluetooth is Off', + message: 'Please enable Bluetooth to scan for MeshCore devices.', ); } @@ -1266,71 +1369,150 @@ class _ConnectionScreenState extends State with WidgetsBinding message += '\n\nNearest zone is $zoneDisplay, $dist away.'; } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.public_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Region Not Available', - message: message, - action: OutlinedButton.icon( - onPressed: () => _launchOnboardingUrl(), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('Request Region Onboarding'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.public_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Region Not Available', + message: message, + action: OutlinedButton.icon( + onPressed: () => _launchOnboardingUrl(), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Request Region Onboarding'), + ), ); } // Show zone checking status on initial startup (before zone is known) if (appState.inZone == null && !appState.offlineMode) { if (appState.zoneCheckError != null) { - // Zone check failed — show error with countdown final countdown = appState.zoneCheckRetryCountdown; - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.cloud_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Zone Check Failed', - message: countdown > 0 - ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' - : '${appState.zoneCheckError}\n\nRetrying...', + + // Network error — show offline mode option (matches maintenance pattern) + if (appState.zoneCheckErrorReason == 'network') { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off, + size: 64, + color: Colors.deepOrange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'No Internet Connection', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Unable to reach MeshMapper. Check your connection, or wardrive offline.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + countdown > 0 + ? 'Retrying in ${countdown}s...' + : 'Retrying...', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle(fontSize: 13, color: Colors.blue.shade700), + ), + ), + ], + ), + ), + ], ), ), - ], + ); + } + + // GPS errors — no auto-retry, show manual retry button + if (appState.zoneCheckErrorReason == 'gps_inaccurate' || + appState.zoneCheckErrorReason == 'gps_stale') { + return _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: appState.zoneCheckErrorReason == 'gps_inaccurate' + ? 'GPS Accuracy Error' + : 'GPS Stale Error', + message: '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + action: FilledButton.icon( + onPressed: () => appState.checkZoneStatus(), + icon: const Icon(Icons.refresh), + label: const Text('Retry Zone Check'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ); + } + + // Other non-network errors — show simple error with countdown + return _buildMessageContent( + context: context, + icon: Icons.cloud_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Zone Check Failed', + message: countdown > 0 + ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' + : '${appState.zoneCheckError}\n\nRetrying...', ); } // Zone check in progress or waiting for GPS - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_searching, - iconColor: Colors.blue.withValues(alpha: 0.7), - title: 'Checking MeshMapper Zone...', - message: 'Verifying your location with MeshMapper', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.location_searching, + iconColor: Colors.blue.withValues(alpha: 0.7), + title: 'Checking MeshMapper Zone...', + message: 'Verifying your location with MeshMapper', ); } if (appState.isScanning) { return Column( children: [ - _buildZoneStatusBar(context, appState), const LinearProgressIndicator(), Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), ], @@ -1341,12 +1523,7 @@ class _ConnectionScreenState extends State with WidgetsBinding // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; if (!kIsWeb && remembered != null) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect)), - ], - ); + return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); } // Show GPS disabled message when location services are off @@ -1354,74 +1531,48 @@ class _ConnectionScreenState extends State with WidgetsBinding // iOS doesn't allow opening Location Services directly, so no button on iOS final isIOS = !kIsWeb && Platform.isIOS; - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.gps_off, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Location Services Disabled', - message: 'Please enable Location Services to verify you\'re in an allowed zone.', - action: isIOS - ? null - : ElevatedButton.icon( - onPressed: () => Geolocator.openLocationSettings(), - icon: const Icon(Icons.settings), - label: const Text('Open Location Settings'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Location Services Disabled', + message: 'Please enable Location Services to verify you\'re in an allowed zone.', + action: isIOS + ? null + : ElevatedButton.icon( + onPressed: () => Geolocator.openLocationSettings(), + icon: const Icon(Icons.settings), + label: const Text('Open Location Settings'), + ), ); } // Show GPS permission required message when permissions are denied if (appState.gpsStatus == GpsStatus.permissionDenied) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'GPS Permission Required', - message: 'Location access is needed to verify you\'re in an allowed zone.', - action: ElevatedButton.icon( - onPressed: () => _requestLocationPermission(appState), - icon: const Icon(Icons.location_on), - label: const Text('Enable Location'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.location_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'GPS Permission Required', + message: 'Location access is needed to verify you\'re in an allowed zone.', + action: ElevatedButton.icon( + onPressed: () => _requestLocationPermission(appState), + icon: const Icon(Icons.location_on), + label: const Text('Enable Location'), + ), ); } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_searching, - iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), - title: 'No devices found', - message: 'Tap Scan to search for MeshCore devices', - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_searching, + iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + title: 'No devices found', + message: 'Tap Scan to search for MeshCore devices', ); } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), - ], - ); + return _buildDeviceListView(context, appState, canConnect: canConnect); } void _launchOnboardingUrl() async { @@ -1543,6 +1694,82 @@ class _ConnectionScreenState extends State with WidgetsBinding }, ); } + + void _showPathHashWarning(BuildContext context, int hopBytes, String reason) { + if (reason == 'firmware_unsupported') { + // Firmware too old to support multi-byte paths + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning_amber, size: 24, color: Colors.amber), + SizedBox(width: 8), + Flexible(child: Text('Firmware Update Recommended')), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your regional admin has enabled $hopBytes-byte path mode for this zone, ' + 'but your companion firmware does not support it.', + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + const Text( + 'The app will operate in 1-byte mode. Consider updating your companion ' + 'firmware to v1.14.0+ for more accurate repeater identification.', + style: TextStyle(fontSize: 13), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } else { + // Mode changed successfully + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.linear_scale, size: 24), + SizedBox(width: 8), + Flexible(child: Text('Multi-Byte Paths Enabled')), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your radio has been updated from 1-byte to $hopBytes-byte paths for this session ($reason).', + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + const Text( + 'Your radio will remain in multi-byte mode until you change it.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } } class _DeviceListTile extends StatelessWidget { diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index b9a4141..977ce8c 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -19,12 +19,13 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('Noise Floor History'), + toolbarHeight: 40, + title: const Text('Noise Floor History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) IconButton( - icon: const Icon(Icons.delete_outline), + icon: const Icon(Icons.delete_outline, size: 20), onPressed: () => _confirmClearSessions(context, appState), tooltip: 'Clear all sessions', ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index eacda81..d984a98 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -175,6 +175,14 @@ class _HomeScreenState extends State { onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), + // Trace count + _buildAppBarStatChip( + Icons.route, + appState.pingStats.traceCount, + Colors.cyan, + onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, + ), + const SizedBox(width: 8), // Upload count _buildAppBarStatChip( Icons.cloud_done, @@ -230,7 +238,7 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: const EdgeInsets.all(20), + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -331,6 +339,9 @@ class _HomeScreenState extends State { case 'disc': return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); + case 'trace': + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -880,20 +891,12 @@ class _HomeScreenState extends State { description: 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', ), - // Offline mode toggle - _buildHelpItem( - icon: Icons.cloud_off, - color: Colors.orange, - title: 'Offline Mode', - description: 'Save pings locally instead of uploading immediately. Useful when you have poor connectivity. Upload saved sessions later from the Settings tab.', - ), - - // Sound toggle + // Trace Mode _buildHelpItem( - icon: Icons.volume_up, - color: Colors.blue, - title: 'Sound', - description: 'Sonar tone when sending TX/Discovery pings. Message tone when receiving valid RX packets, heard repeaters, or discovery responses.', + icon: Icons.gps_fixed, + color: Colors.cyan, + title: 'Trace Mode', + description: 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', ), const SizedBox(height: 8), diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index bb8a6a3..97267e0 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -3,10 +3,11 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; +import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../widgets/repeater_id_chip.dart'; -/// Log screen with tabs for TX Log, RX Log, and User Errors +/// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors class LogScreen extends StatefulWidget { const LogScreen({super.key}); @@ -16,11 +17,12 @@ class LogScreen extends StatefulWidget { class _LogScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + final _allPingsKey = GlobalKey<_AllPingsTabState>(); @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + _tabController = TabController(length: 2, vsync: this); } @override @@ -36,227 +38,155 @@ class _LogScreenState extends State with SingleTickerProviderStateMix // Auto-switch to Error tab when requested if (appState.requestErrorLogSwitch) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _tabController.index != 3) { - _tabController.animateTo(3); // Switch to Error tab + if (mounted && _tabController.index != 1) { + _tabController.animateTo(1); // Switch to Error tab setState(() {}); } appState.clearErrorLogSwitchRequest(); }); } + final totalPings = appState.txLogEntries.length + + appState.rxLogEntries.length + + appState.discLogEntries.length + + appState.traceLogEntries.length; + + final errorCount = appState.errorLogEntries.length; + return Scaffold( appBar: AppBar( - title: const Text('Logs'), + toolbarHeight: 40, + title: const Text('Logs', style: TextStyle(fontSize: 18)), actions: [ - IconButton( - icon: const Icon(Icons.copy), - onPressed: () => _copyCurrentTabToCsv(context, appState), - tooltip: 'Copy CSV', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => _confirmClearLogs(context, appState), - tooltip: 'Clear all logs', + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 20), + padding: EdgeInsets.zero, + onSelected: (value) { + if (value == 'copy') _copyCurrentTabToCsv(context, appState); + if (value == 'clear') _confirmClearLogs(context, appState); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), + const PopupMenuItem(value: 'clear', child: Text('Clear all logs')), + ], ), ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(32), + child: TabBar( + controller: _tabController, + indicatorSize: TabBarIndicatorSize.tab, + dividerHeight: 1, + labelPadding: EdgeInsets.zero, + tabs: [ + Tab(height: 32, text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), + Tab(height: 32, text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), + ], + ), + ), ), - body: Column( + body: TabBarView( + controller: _tabController, children: [ - // Secondary bar with tabs (full width) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - Expanded(child: _buildTabChip(0, 'TX', appState.txLogEntries.length, isTx: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(1, 'RX', appState.rxLogEntries.length, isRx: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(2, 'DISC', appState.discLogEntries.length, isDisc: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(3, 'Errors', appState.errorLogEntries.length, isError: true)), - ], - ), - ), - // Tab content - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _TxLogTab(entries: appState.txLogEntries), - _RxLogTab(entries: appState.rxLogEntries), - _DiscLogTab(entries: appState.discLogEntries), - _ErrorLogTab(entries: appState.errorLogEntries), - ], - ), + _AllPingsTab( + key: _allPingsKey, + allEntries: appState.unifiedPingLogEntries, + repeaters: appState.repeaters, + txCount: appState.txLogEntries.length, + rxCount: appState.rxLogEntries.length, + discCount: appState.discLogEntries.length, + traceCount: appState.traceLogEntries.length, ), + _ErrorLogTab(entries: appState.errorLogEntries), ], ), ); } - /// Build a tab chip that matches StatusBar chip styling - Widget _buildTabChip(int index, String label, int count, {bool isError = false, bool isDisc = false, bool isTx = false, bool isRx = false}) { - final theme = Theme.of(context); - // Colors matching status bar chips - const discColor = Color(0xFF7B68EE); // DISC purple - const txColor = Colors.green; // TX green (matches status bar) - const rxColor = Colors.blue; // RX blue (matches status bar) - - return GestureDetector( - onTap: () { - _tabController.animateTo(index); - setState(() {}); - }, - child: AnimatedBuilder( - animation: _tabController, - builder: (context, child) { - final isCurrentlySelected = _tabController.index == index; - Color currentColor; - if (isCurrentlySelected) { - if (isError) { - currentColor = Colors.red; - } else if (isDisc) { - currentColor = discColor; - } else if (isTx) { - currentColor = txColor; - } else if (isRx) { - currentColor = rxColor; - } else { - currentColor = theme.colorScheme.primary; - } - } else { - currentColor = theme.colorScheme.onSurfaceVariant; - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.15 : 0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.4 : 0.2), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isCurrentlySelected ? FontWeight.w600 : FontWeight.w500, - color: currentColor, - ), - ), - if (count > 0) ...[ - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: currentColor, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - count > 99 ? '99+' : count.toString(), - style: const TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), - ); - }, - ), - ); - } - void _copyCurrentTabToCsv(BuildContext context, AppStateProvider appState) { - final currentTab = _tabController.index; - - switch (currentTab) { - case 0: // TX Log - _copyTxLogToCsv(context, appState.txLogEntries); - break; - case 1: // RX Log - _copyRxLogToCsv(context, appState.rxLogEntries); - break; - case 2: // DISC Log - _copyDiscLogToCsv(context, appState.discLogEntries); - break; - case 3: // Error Log - _copyErrorLogToCsv(context, appState.errorLogEntries); - break; + if (_tabController.index == 0) { + _copyAllPingsToCsv(context, appState); + } else { + _copyErrorLogToCsv(context, appState.errorLogEntries); } } - void _copyTxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { + void _copyAllPingsToCsv(BuildContext context, AppStateProvider appState) { + // If a search filter is active, export only the filtered unified entries + final tabState = _allPingsKey.currentState; + final searchQuery = tabState?._searchQuery ?? ''; + if (searchQuery.isNotEmpty && tabState != null) { + final filtered = tabState._filteredEntries; + if (filtered.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No matching entries to copy'), duration: Duration(seconds: 2)), + ); + return; + } + final buffer = StringBuffer(); + buffer.writeln('type,data'); + for (final entry in filtered) { + buffer.writeln(entry.toCsv()); + } + Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No TX log entries to copy'), duration: Duration(seconds: 2)), + SnackBar(content: Text('${filtered.length} filtered entries copied to clipboard'), duration: const Duration(seconds: 2)), ); return; } - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,power,events'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); - } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } + final tx = appState.txLogEntries; + final rx = appState.rxLogEntries; + final disc = appState.discLogEntries; + final trace = appState.traceLogEntries; - void _copyRxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { + if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No RX log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('No ping log entries to copy'), duration: Duration(seconds: 2)), ); return; } final buffer = StringBuffer(); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + + if (tx.isNotEmpty) { + buffer.writeln('--- TX Log ---'); + buffer.writeln('timestamp,latitude,longitude,power,events'); + for (final entry in tx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('RX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } - void _copyDiscLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No DISC log entries to copy'), duration: Duration(seconds: 2)), - ); - return; + if (rx.isNotEmpty) { + buffer.writeln('--- RX Log ---'); + buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + for (final entry in rx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + if (disc.isNotEmpty) { + buffer.writeln('--- DISC Log ---'); + buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + for (final entry in disc) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); + } + + if (trace.isNotEmpty) { + buffer.writeln('--- TRC Log ---'); + buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + for (final entry in trace) { + buffer.writeln(entry.toCsv()); + } } + Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('DISC log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar(content: Text('All ping logs copied to clipboard'), duration: Duration(seconds: 2)), ); } @@ -284,7 +214,7 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, and error logs.'), + content: const Text('This will clear TX, RX, DISC, TRC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -303,231 +233,349 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } } -/// TX Log Tab -class _TxLogTab extends StatelessWidget { - final List entries; +// ============================================================================= +// All Pings Tab — unified chronological view with type filters +// ============================================================================= + +class _AllPingsTab extends StatefulWidget { + final List allEntries; + final List repeaters; + final int txCount; + final int rxCount; + final int discCount; + final int traceCount; + + const _AllPingsTab({ + super.key, + required this.allEntries, + required this.repeaters, + required this.txCount, + required this.rxCount, + required this.discCount, + required this.traceCount, + }); - const _TxLogTab({required this.entries}); + @override + State<_AllPingsTab> createState() => _AllPingsTabState(); +} + +class _AllPingsTabState extends State<_AllPingsTab> { + final Set _activeFilters = { + PingLogType.tx, + PingLogType.rx, + PingLogType.disc, + PingLogType.trace, + }; + + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + + /// Current filtered entries (used by CSV export) + List _filteredEntries = []; @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.upload_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No TX pings logged yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ); - } + void dispose() { + _searchController.dispose(); + super.dispose(); + } - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildTxEntry(context, entry); - }, - ); + void _toggleFilter(PingLogType type) { + setState(() { + if (_activeFilters.contains(type)) { + // Don't allow deselecting the last filter + if (_activeFilters.length > 1) { + _activeFilters.remove(type); + } + } else { + _activeFilters.add(type); + } + }); } - Widget _buildTxEntry(BuildContext context, TxLogEntry entry) { - final appState = context.read(); + // --------------------------------------------------------------------------- + // Repeater name resolution & search matching + // --------------------------------------------------------------------------- + + /// Resolve a short repeater ID to known repeater names via prefix matching. + static ({List names, bool ambiguous}) _resolveRepeaterNames( + String repeaterId, List repeaters, + ) { + final idLower = repeaterId.toLowerCase(); + final matches = repeaters + .where((r) => r.hexId.toLowerCase().startsWith(idLower)) + .map((r) => r.name) + .toList(); + return (names: matches, ambiguous: matches.length > 1); + } - return Card( - margin: const EdgeInsets.only(bottom: 8), - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time and Power - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + /// Whether a hex ID has ambiguous name matches (maps to multiple repeaters). + static bool _isAmbiguousId(String repeaterId, List repeaters) { + return _resolveRepeaterNames(repeaterId, repeaters).ambiguous; + } + + /// True if the query looks like a hex string (only 0-9, a-f). + static bool _isHexQuery(String query) { + return RegExp(r'^[0-9a-fA-F]+$').hasMatch(query); + } + + /// Whether an entry matches the current search query. + bool _matchesSearch(UnifiedPingLogEntry entry, List repeaters) { + if (_searchQuery.isEmpty) return true; + final query = _searchQuery.toLowerCase(); + + switch (entry.type) { + case PingLogType.tx: + final tx = entry.asTx; + for (final event in tx.events) { + if (event.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + } + return false; + case PingLogType.rx: + final rx = entry.asRx; + if (rx.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(rx.repeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + case PingLogType.disc: + final disc = entry.asDisc; + for (final node in disc.discoveredNodes) { + if (node.repeaterId.toLowerCase().startsWith(query)) return true; + if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + } + return false; + case PingLogType.trace: + final trace = entry.asTrace; + if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(trace.targetRepeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + } + } + + /// Whether an entry should show the ambiguity indicator. + /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. + bool _shouldShowAmbiguity(UnifiedPingLogEntry entry, List repeaters) { + if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; + + switch (entry.type) { + case PingLogType.tx: + return entry.asTx.events.any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + case PingLogType.rx: + return _isAmbiguousId(entry.asRx.repeaterId, repeaters); + case PingLogType.disc: + return entry.asDisc.discoveredNodes.any((n) => _isAmbiguousId(n.repeaterId, repeaters)); + case PingLogType.trace: + return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); + } + } + + @override + Widget build(BuildContext context) { + final filtered = widget.allEntries + .where((e) => _activeFilters.contains(e.type)) + .where((e) => _matchesSearch(e, widget.repeaters)) + .toList(); + _filteredEntries = filtered; + + final hasEntries = widget.allEntries.isNotEmpty; + final hasResults = filtered.isNotEmpty; + + return Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: SizedBox( + height: 36, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + hintText: 'Search by repeater name or ID', + hintStyle: const TextStyle(fontSize: 13), + prefixIcon: const Icon(Icons.search, size: 18), + prefixIconConstraints: const BoxConstraints(minWidth: 36), + suffixIcon: _searchQuery.isNotEmpty + ? GestureDetector( + onTap: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + child: const Icon(Icons.close, size: 18), + ) + : null, + suffixIconConstraints: const BoxConstraints(minWidth: 36), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), ), - const Spacer(), - // Power indicator (watts) - Text( - '${entry.power.toStringAsFixed(1)} W', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), ), - ], + ), + onChanged: (value) => setState(() => _searchQuery = value.trim()), ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], + ), + ), + // Filter segmented row + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), ), - - // Repeaters table - if (entry.events.isNotEmpty) ...[ - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + children: [ + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, Colors.green, isFirst: true), + _segmentDivider(context), + _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, Colors.blue), + _segmentDivider(context), + _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, const Color(0xFF7B68EE)), + _segmentDivider(context), + _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, Colors.cyan, isLast: true), + ], + ), + ), + ), + ), + // List + Expanded( + child: !hasResults + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasEntries ? Icons.search_off : Icons.list_alt, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.events.map((event) => _buildRepeaterRow(context, event)), - ], + const SizedBox(height: 16), + Text( + hasEntries && _searchQuery.isNotEmpty + ? 'No results for \'$_searchQuery\'' + : 'No pings logged yet', + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + itemCount: filtered.length, + itemBuilder: (context, index) { + final unified = filtered[index]; + final showAmbiguity = _shouldShowAmbiguity(unified, widget.repeaters); + return switch (unified.type) { + PingLogType.tx => _buildTxCard(context, unified.asTx, showAmbiguity: showAmbiguity), + PingLogType.rx => _buildRxCard(context, unified.asRx, showAmbiguity: showAmbiguity), + PingLogType.disc => _buildDiscCard(context, unified.asDisc, showAmbiguity: showAmbiguity), + PingLogType.trace => _buildTraceCard(context, unified.asTrace, showAmbiguity: showAmbiguity), + }; + }, ), - ), - ] else ...[ - const SizedBox(height: 8), + ), + ], + ); + } + + Widget _buildFilterSegment(PingLogType type, String label, int count, Color color, {bool isFirst = false, bool isLast = false}) { + final active = _activeFilters.contains(type); + return Expanded( + child: GestureDetector( + onTap: () => _toggleFilter(type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 7), + color: active ? color.withValues(alpha: 0.12) : Colors.transparent, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - 'No repeaters heard', + label, style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + fontSize: 13, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), ), ), + if (count > 0) ...[ + const SizedBox(width: 4), + Container( + constraints: const BoxConstraints(minWidth: 18, minHeight: 16), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + count > 99 ? '99+' : count.toString(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.0, + ), + ), + ), + ], ], - ], + ), ), ), - ), ); } - /// Build a table row for a repeater event - Widget _buildRepeaterRow(BuildContext context, RxEvent event) { - Color snrColor; - switch (event.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (event.rssi >= -70) { - rssiColor = Colors.green; - } else if (event.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + static Widget _segmentDivider(BuildContext context) { + return VerticalDivider( + width: 1, + thickness: 1, + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ); + } - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 50), - // SNR - Expanded( - child: Center( - child: _buildTxChip(event.snr.toStringAsFixed(1), snrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildTxChip('${event.rssi}', rssiColor), - ), - ), - ], + // --------------------------------------------------------------------------- + // Type badge + // --------------------------------------------------------------------------- + + static Widget _buildTypeBadge(PingLogType type) { + final (label, color) = switch (type) { + PingLogType.tx => ('TX', Colors.green), + PingLogType.rx => ('RX', Colors.blue), + PingLogType.disc => ('DISC', const Color(0xFF7B68EE)), + PingLogType.trace => ('TRC', Colors.cyan), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, ), ), ); } - /// Build a small colored chip for TX table cells - Widget _buildTxChip(String value, Color color) { + // --------------------------------------------------------------------------- + // Shared chip builder + // --------------------------------------------------------------------------- + + static Widget _buildChip(String value, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( @@ -538,7 +586,7 @@ class _TxLogTab extends StatelessWidget { child: Text( value, style: TextStyle( - fontSize: 10, + fontSize: 11, fontWeight: FontWeight.w600, color: color, fontFamily: 'monospace', @@ -546,326 +594,172 @@ class _TxLogTab extends StatelessWidget { ), ); } -} - -/// RX Log Tab -class _RxLogTab extends StatelessWidget { - final List entries; - - const _RxLogTab({required this.entries}); - - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.download_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No RX observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ); - } - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildRxEntry(context, entry); - }, - ); - } + // --------------------------------------------------------------------------- + // TX Card + // --------------------------------------------------------------------------- - Widget _buildRxEntry(BuildContext context, RxLogEntry entry) { + Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); - - Color snrColor; - switch (entry.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (entry.rssi >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (entry.rssi >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm - } - return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time badge only - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + // Repeaters table + if (entry.events.isNotEmpty) ...[ + const SizedBox(height: 10), + _buildRepeaterTable(context, entry.events), + ] else ...[ + const SizedBox(height: 8), Text( - entry.locationString, + 'No repeaters heard', style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', + fontStyle: FontStyle.italic, ), ), ], - ), - const SizedBox(height: 10), - - // Repeater table (single row) - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data row - InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 50), - // SNR - Expanded( - child: Center( - child: _buildRxChip(entry.snr.toStringAsFixed(1), snrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildRxChip('${entry.rssi}', rssiColor), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ], + ], + ), ), ), - ), ); } - /// Build a small colored chip for RX table cells - Widget _buildRxChip(String value, Color color) { + Widget _buildRepeaterTable(BuildContext context, List events) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', - ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 60, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...events.map((event) => _buildTxRepeaterRow(context, event)), + ], ), ); } -} -/// DISC Log Tab (Discovery observations from Passive Mode) -class _DiscLogTab extends StatelessWidget { - final List entries; - - const _DiscLogTab({required this.entries}); - - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, + Widget _buildTxRepeaterRow(BuildContext context, RxEvent event) { + final snrColor = _snrColor(event.severity); + final rssiColor = _rssiColor(event.rssi); + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( children: [ - Icon(Icons.radar_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No discovery observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - Text('Enable Passive Mode to discover nearby nodes', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12)), + RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 14, width: 60), + Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), ], ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - final entry = entries[index]; // Already sorted most recent first - return _buildDiscEntry(context, entry); - }, + ), ); } - Widget _buildDiscEntry(BuildContext context, DiscLogEntry entry) { + // --------------------------------------------------------------------------- + // RX Card + // --------------------------------------------------------------------------- + + Widget _buildRxCard(BuildContext context, RxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); + final snrColor = _snrColor(entry.severity); + final rssiColor = _rssiColor(entry.rssi); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - // Navigate to map and show this location - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header row: Time and node count (matching TX log style) - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, + _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + const SizedBox(height: 10), + // Repeater table (single row) + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 60, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], ), ), - ), - const Spacer(), - // Node count indicator (like power indicator in TX log) - Text( - '${entry.nodeCount} node${entry.nodeCount == 1 ? '' : 's'}', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', + Divider(height: 1, color: Theme.of(context).dividerColor), + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 14, width: 60), + Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), + ], + ), + ), ), - ), - ], + ], + ), ), - const SizedBox(height: 8), + ], + ), + ), + ), + ); + } - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), + // --------------------------------------------------------------------------- + // DISC Card + // --------------------------------------------------------------------------- + + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, {bool showAmbiguity = false}) { + final appState = context.read(); + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), @@ -877,61 +771,19 @@ class _DiscLogTab extends StatelessWidget { ), child: Column( children: [ - // Header row Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + SizedBox(width: 70, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'RX SNR', center: true)), + Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), + Expanded(child: _tableHeader(context, 'TX SNR', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) => _buildNodeRow(context, node)), + ...entry.discoveredNodes.map((node) => _buildDiscNodeRow(context, node)), ], ), ), @@ -940,7 +792,7 @@ class _DiscLogTab extends StatelessWidget { Text( 'No nodes discovered', style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), @@ -953,20 +805,9 @@ class _DiscLogTab extends StatelessWidget { ); } - /// Build a table row for a discovered node - Widget _buildNodeRow(BuildContext context, DiscoveredNodeEntry node) { - // Color for RX SNR (what we received) - Color rxSnrColor; - switch (node.severity) { - case SnrSeverity.poor: - rxSnrColor = Colors.red; - case SnrSeverity.fair: - rxSnrColor = Colors.orange; - case SnrSeverity.good: - rxSnrColor = Colors.green; - } - - // TX SNR color (what they received from us) + Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { + final rxSnrColor = _snrColorFromValue(node.localSnr); + final rssiColor = _rssiColor(node.localRssi); Color txSnrColor; if (node.remoteSnr <= -1) { txSnrColor = Colors.red; @@ -976,32 +817,21 @@ class _DiscLogTab extends StatelessWidget { txSnrColor = Colors.green; } - // RSSI color based on signal strength - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - // Node ID with type SizedBox( - width: 50, + width: 70, child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11), + Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, style: const TextStyle( - fontSize: 9, + fontSize: 11, fontWeight: FontWeight.w500, color: Color(0xFF7B68EE), ), @@ -1009,53 +839,181 @@ class _DiscLogTab extends StatelessWidget { ], ), ), - // RX SNR - Expanded( - child: Center( - child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildChip('${node.localRssi}', rssiColor), - ), - ), - // TX SNR - Expanded( - child: Center( - child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor), - ), - ), + Expanded(child: Center(child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor))), + Expanded(child: Center(child: _buildChip('${node.localRssi}', rssiColor))), + Expanded(child: Center(child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor))), ], ), ), ); } - /// Build a small colored chip for table cells - Widget _buildChip(String value, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), + // --------------------------------------------------------------------------- + // Trace Card + // --------------------------------------------------------------------------- + + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, {bool showAmbiguity = false}) { + final colorScheme = Theme.of(context).colorScheme; + final appState = context.read(); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + // Results table + if (entry.success) ...[ + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: 70, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'RX SNR', center: true)), + Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), + Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + _buildTraceNodeRow(context, entry), + ], + ), + ), + ] else ...[ + const SizedBox(height: 8), + Text( + 'No response', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', + ); + } + + Widget _buildTraceNodeRow(BuildContext context, TraceLogEntry entry) { + final rxSnrColor = _snrColorFromNullableValue(entry.localSnr); + final rssiColor = _rssiColor(entry.localRssi); + final txSnrColor = _snrColorFromNullableValue(entry.remoteSnr); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 14)), + Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), + Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), + Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------- + + static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString, {bool showAmbiguity = false}) { + return Row( + children: [ + _buildTypeBadge(type), + if (showAmbiguity) ...[ + const SizedBox(width: 2), + Tooltip( + message: 'Repeater ID matches multiple nodes', + child: Icon(Icons.help_outline, size: 14, color: Colors.amber.shade700), + ), + ], + const SizedBox(width: 6), + Text( + timeString, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const Spacer(), + Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 2), + Text( + locationString, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), ), + ], + ); + } + + static Widget _tableHeader(BuildContext context, String text, {bool center = false}) { + return Text( + text, + textAlign: center ? TextAlign.center : TextAlign.left, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } + + static Color _snrColor(SnrSeverity? severity) { + return switch (severity) { + SnrSeverity.poor => Colors.red, + SnrSeverity.fair => Colors.orange, + SnrSeverity.good => Colors.green, + null => Colors.grey, + }; + } + + static Color _snrColorFromValue(double snr) { + if (snr <= -1) return Colors.red; + if (snr <= 5) return Colors.orange; + return Colors.green; + } + + static Color _snrColorFromNullableValue(double? snr) { + if (snr == null) return Colors.grey; + return _snrColorFromValue(snr); + } + + static Color _rssiColor(int? rssi) { + if (rssi == null) return Colors.grey; + if (rssi >= -70) return Colors.green; + if (rssi >= -100) return Colors.orange; + return Colors.red; + } } -/// Error Log Tab +// ============================================================================= +// Error Log Tab (unchanged) +// ============================================================================= + class _ErrorLogTab extends StatelessWidget { final List entries; @@ -1080,7 +1038,7 @@ class _ErrorLogTab extends StatelessWidget { padding: const EdgeInsets.all(16), itemCount: entries.length, itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) + // Most recent first final entry = entries[entries.length - 1 - index]; return _buildErrorEntry(context, entry); }, diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index b0bc903..87c2c43 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -26,6 +26,7 @@ class MainScaffold extends StatefulWidget { class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; + bool _hasShownLocationSettingsPrompt = false; final List _screens = [ const HomeScreen(), @@ -59,18 +60,15 @@ class _MainScaffoldState extends State { // Check if disclosure was already shown final hasShown = await PermissionDisclosureService.hasShownDisclosure(); - if (hasShown) { - debugLog('[DISCLOSURE] Already shown, skipping'); - return; + if (!hasShown) { + // Show the disclosure dialog + if (!mounted) return; + debugLog('[DISCLOSURE] Showing location disclosure dialog'); + await PermissionDisclosureService.showLocationDisclosure(context); } - // Show the disclosure dialog - if (!mounted) return; - debugLog('[DISCLOSURE] Showing location disclosure dialog'); - await PermissionDisclosureService.showLocationDisclosure(context); - - debugLog('[DISCLOSURE] User acknowledged, requesting permissions'); - await _requestPermissionsAfterDisclosure(); + debugLog('[DISCLOSURE] Ensuring location permission after disclosure'); + await _ensureLocationPermission(); } /// Request GPS permission on web (triggers browser's native prompt) @@ -93,8 +91,10 @@ class _MainScaffoldState extends State { } } - /// Request permissions after user accepts disclosure - Future _requestPermissionsAfterDisclosure() async { + /// Ensure location permission after disclosure has been shown. + /// Requests when possible, restarts GPS when granted, and surfaces a settings CTA + /// when the permission has been permanently denied. + Future _ensureLocationPermission() async { bool granted = false; if (Platform.isIOS) { @@ -104,12 +104,23 @@ class _MainScaffoldState extends State { permission = await Geolocator.requestPermission(); } debugLog('[DISCLOSURE] iOS location permission: $permission'); + if (permission == LocationPermission.deniedForever) { + _showLocationSettingsPrompt(); + return; + } granted = permission == LocationPermission.always || permission == LocationPermission.whileInUse; } else { - // Android: Request location via permission_handler - final status = await Permission.locationWhenInUse.request(); + // Android: only request if needed so previously granted permission just restarts GPS. + var status = await Permission.locationWhenInUse.status; + if (status.isDenied) { + status = await Permission.locationWhenInUse.request(); + } debugLog('[DISCLOSURE] Android location permission: $status'); + if (status.isPermanentlyDenied) { + _showLocationSettingsPrompt(); + return; + } granted = status.isGranted; } @@ -121,6 +132,21 @@ class _MainScaffoldState extends State { } } + void _showLocationSettingsPrompt() { + if (!mounted || _hasShownLocationSettingsPrompt) return; + _hasShownLocationSettingsPrompt = true; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission is disabled in system settings.'), + action: SnackBarAction( + label: 'Settings', + onPressed: Geolocator.openAppSettings, + ), + ), + ); + } + @override Widget build(BuildContext context) { final appState = context.watch(); @@ -149,6 +175,18 @@ class _MainScaffoldState extends State { }); } + // Listen for connection tab requests - switch to Connect tab (e.g. anonymous mode reconnect) + if (appState.requestConnectionTabSwitch && _selectedIndex != 3) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _selectedIndex = 3; // Switch to Connect tab + }); + appState.clearConnectionTabSwitchRequest(); + } + }); + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b013208..c99446f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -14,17 +14,18 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import '../utils/web_file_helpers_stub.dart' if (dart.library.html) '../utils/web_file_helpers.dart'; +import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; import '../models/user_preferences.dart'; import '../services/debug_file_logger.dart'; -import '../services/debug_submit_service.dart'; import '../services/gps_simulator_service.dart'; import '../services/offline_session_service.dart'; import '../services/permission_disclosure_service.dart'; import '../utils/constants.dart'; import '../widgets/bug_report_dialog.dart'; +import '../widgets/upload_logs_dialog.dart'; import 'package:intl/intl.dart'; import '../widgets/app_toast.dart'; @@ -41,93 +42,19 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - // Debug log upload tracking - String? _uploadingFilePath; - final Set _uploadedFiles = {}; + Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { + final result = await showUploadLogsDialog(context, appState); - Future _uploadSingleLogFile(AppStateProvider appState, File file) async { - final filename = file.path.split('/').last; - - setState(() { - _uploadingFilePath = file.path; - }); - - try { - final service = DebugSubmitService(); - - // Use persistent device info - final publicKey = appState.devicePublicKey ?? - appState.lastConnectedPublicKey ?? - 'not-connected'; - final deviceName = appState.lastConnectedDeviceName ?? 'not-connected'; - - final success = await service.uploadDebugFileOnly( - file: file, - deviceId: deviceName, - publicKey: publicKey, - appVersion: AppConstants.appVersion, - devicePlatform: DebugSubmitService.getDevicePlatform(), - userNotes: 'Direct debug log upload from $deviceName', - ); - - service.dispose(); - - if (!mounted) return; - - if (success) { - setState(() { - _uploadedFiles.add(file.path); - _uploadingFilePath = null; - }); - AppToast.success(context, 'Uploaded $filename'); - } else { - setState(() { - _uploadingFilePath = null; - }); - AppToast.error(context, 'Failed to upload $filename'); - } - } catch (e) { - debugError('[SETTINGS] Log file upload error: $e'); - if (mounted) { - setState(() { - _uploadingFilePath = null; - }); - AppToast.error(context, 'Upload error: $e'); - } - } - } - - /// Upload the current (active) log file by rotating it first - Future _uploadCurrentLogFile(AppStateProvider appState, File currentFile) async { - final originalPath = currentFile.path; - - setState(() { - _uploadingFilePath = originalPath; - }); - - try { - // Rotate the log: closes current file, starts a new one - // The original file is now closed and safe to upload - await appState.prepareDebugLogsForUpload(); - - if (!mounted) return; - - // The original file still exists at the same path, now closed - final closedFile = File(originalPath); - if (!closedFile.existsSync()) { - throw Exception('Log file not found after rotation'); - } + if (!context.mounted || result == null) return; - // Now upload the closed file - await _uploadSingleLogFile(appState, closedFile); - } catch (e) { - debugError('[SETTINGS] Current log upload error: $e'); - if (mounted) { - setState(() { - _uploadingFilePath = null; - }); - AppToast.error(context, 'Upload error: $e'); + if (result.success) { + String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; + if (result.failedCount > 0) { + message += ' (${result.failedCount} failed)'; } + AppToast.success(context, message); + } else if (result.errorMessage != null) { + AppToast.error(context, result.errorMessage!); } } @@ -175,633 +102,807 @@ class _SettingsScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Settings'), + toolbarHeight: 40, + title: const Text('Settings', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, ), body: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), children: [ - // Appearance section - _buildSectionHeader(context, 'Appearance'), - ListTile( - leading: Icon( - prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, + // Lock indicator + if (isAutoMode) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + ), + child: const Row( + children: [ + Icon(Icons.lock, size: 16, color: Colors.amber), + SizedBox(width: 8), + Text( + 'Some settings locked during auto-ping', + style: TextStyle(fontSize: 12, color: Colors.amber), + ), + ], + ), + ), ), - title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), - trailing: Switch( + + // General + _buildSection(context, 'General', [ + SwitchListTile( + secondary: Icon( + prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, + ), + title: const Text('Theme'), + subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), value: prefs.themeMode == 'dark', onChanged: (isDark) { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), - ), - ListTile( - leading: Icon( - prefs.isImperial ? Icons.square_foot : Icons.straighten, - ), - title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), - trailing: Switch( + SwitchListTile( + secondary: Icon( + prefs.isImperial ? Icons.square_foot : Icons.straighten, + ), + title: const Text('Units'), + subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), value: prefs.isImperial, onChanged: (isImperial) { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); }, ), - ), - - const Divider(), - - // Wardriving Settings section - _buildSectionHeader(context, 'Wardriving Settings'), - - // Auto-Ping Interval Selector - ListTile( - leading: const Icon(Icons.timer), - title: const Text('Auto-Ping Interval'), - subtitle: Text(prefs.autoPingIntervalDisplay), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), - ), - - // Hybrid Mode Toggle - SwitchListTile( - secondary: const Icon(Icons.compare_arrows), - title: Row( - children: [ - const Text('Hybrid Mode'), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showHybridModeInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + SwitchListTile( + secondary: const Icon(Icons.cell_tower), + title: const Text('Top Repeaters on Map'), + subtitle: const Text('Show top 3 repeaters by SNR from last ping'), + value: prefs.showTopRepeaters, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); + }, ), - subtitle: const Text('Combines Active and Passive modes'), - value: prefs.hybridModeEnabled, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, - ), + if (!kIsWeb) + _BackgroundModeToggle(appState: appState), + ]), - // Carpeater Ignore Setting - SwitchListTile( - secondary: const Icon(Icons.filter_alt), - title: const Text('Ignore Carpeater'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Filtering repeater 0x${prefs.ignoreRepeaterId}' - : 'Tap to set repeater ID to ignore'), - value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - // Show dialog to set repeater ID when enabling - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, - ), - - // Repeater ID to Ignore - show when enabled - if (prefs.ignoreCarpeater) + // Ping Settings + _buildSection(context, 'Ping Settings', [ + SwitchListTile( + secondary: const Icon(Icons.visibility_off), + title: const Text('Anonymous Mode'), + subtitle: Text(prefs.anonymousMode + ? 'Device broadcasts as "Anonymous"' + : 'Device uses its real name'), + value: prefs.anonymousMode, + onChanged: isAutoMode ? null : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, + ), ListTile( - leading: const SizedBox(width: 24), // Indent - title: const Text('Repeater ID'), - subtitle: Text(prefs.ignoreRepeaterId != null - ? '0x${prefs.ignoreRepeaterId}' - : 'Not set'), + leading: const Icon(Icons.timer), + title: const Text('Auto-Ping Interval'), + subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), ), + ListTile( + leading: const Icon(Icons.straighten), + title: const Text('Min Ping Distance'), + subtitle: Text(prefs.minPingDistanceDisplay), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + ), + SwitchListTile( + secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + SwitchListTile( + secondary: const Icon(Icons.timer_off), + title: const Text('Auto-Stop After Idle'), + subtitle: const Text('Stops auto-ping after 30 min without movement'), + value: prefs.autoStopAfterIdle, + onChanged: isAutoMode ? null : (value) { + appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); + }, + ), + ]), - // Lock indicator - if (isAutoMode) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( + // Modes + _buildSection(context, 'Modes', [ + SwitchListTile( + secondary: const Icon(Icons.compare_arrows), + title: Row( children: [ - Icon(Icons.lock, size: 16, color: Colors.orange.shade700), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Settings locked during auto-ping mode', - style: TextStyle( - fontSize: 12, - color: Colors.orange.shade700, - fontStyle: FontStyle.italic, - ), + const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showHybridModeInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), + subtitle: appState.enforceHybrid + ? const Text( + 'Set by Regional Admin — hybrid uses 50% fewer flood packets, improving mesh health.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Combines Active and Passive modes'), + value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, + onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { + appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); + }, ), - - // Background Mode - for "Always" location permission (iOS and Android) - if (!kIsWeb) ...[ - const Divider(), - _buildSectionHeader(context, 'Background Mode'), - _BackgroundModeToggle(appState: appState), - ], - - const Divider(), - - // Data Management section - _buildSectionHeader(context, 'Data Management'), - ListTile( - leading: const Icon(Icons.cloud_queue), - title: const Text('Queued Pings'), - subtitle: Text('${appState.queueSize} items waiting'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.cloud_upload), - onPressed: appState.queueSize > 0 - ? () => appState.forceUploadQueue() - : null, - tooltip: 'Force upload', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: appState.queueSize > 0 - ? () => _confirmClearQueue(context, appState) - : null, - tooltip: 'Clear queue', - ), - ], - ), - ), - - // Clear Map Markers - ListTile( - leading: const Icon(Icons.delete_sweep), - title: const Text('Clear Map Markers'), - subtitle: const Text('Remove all TX/RX markers from map'), - onTap: () => _confirmClearPings(context, appState), - ), - - // Offline Sessions - if (appState.offlineSessions.isNotEmpty) ...[ - _buildSectionHeader(context, 'Offline Sessions'), - ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), - ], - - const Divider(), - - // Device Info section - _buildSectionHeader(context, 'Device'), - ListTile( - leading: const Icon(Icons.perm_identity), - title: const Text('Device ID'), - subtitle: Text(appState.deviceId), - ), - - const Divider(), - - // About section - _buildSectionHeader(context, 'About'), - const ListTile( - leading: Icon(Icons.info_outline), - title: Text(AppConstants.appName), - ), - GestureDetector( - onTap: () => _onVersionTap(appState), - child: ListTile( - leading: const Icon(Icons.new_releases_outlined), - title: const Text('Version'), - subtitle: Text(AppConstants.appVersion), - ), - ), - ListTile( - leading: const Icon(Icons.feedback_outlined), - title: const Text('Submit Feedback'), - subtitle: const Text('Report bugs or request features'), - onTap: () => _showBugReportDialog(context, appState), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.github), - title: const Text('GitHub'), - subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.discord), - title: const Text('Discord'), - subtitle: const Text('Join our community chat'), - onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), - ), - ListTile( - leading: const Icon(Icons.groups), - title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), - onTap: () => _launchUrl('https://ottawamesh.ca/'), - ), - ListTile( - leading: const Icon(Icons.coffee), - title: const Text('Buy us a coffee ☕'), - subtitle: const Text('Support MeshMapper development'), - onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), - ), - - // Exit Options (Android only) - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) ...[ - const Divider(), - _buildSectionHeader(context, 'Exit Options'), - - // Auto-close toggle SwitchListTile( - secondary: const Icon(Icons.exit_to_app), - title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), - value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + secondary: const Icon(Icons.signal_wifi_off), + title: Row( + children: [ + const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showDiscDropInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + subtitle: appState.enforceDiscDrop + ? const Text( + 'Set by Regional Admin — reports dead zones for network analysis.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Count failed discoveries as failed pings'), + value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, + onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); + } + }, ), + ]), - // Manual close button - ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), - title: const Text('Close App'), - subtitle: const Text('Exit the app completely'), - onTap: () => _showCloseAppConfirmation(context, appState), + // Filtering + _buildSection(context, 'Filtering', [ + SwitchListTile( + secondary: const Icon(Icons.filter_alt), + title: const Text('CARpeater Filter'), + subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), + value: prefs.ignoreCarpeater, + onChanged: isAutoMode ? null : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); + } + }, ), - ], - - // Developer Tools section - only visible when developer mode is enabled - if (appState.developerModeEnabled) ...[ - const Divider(), - - _buildSectionHeader(context, 'Developer Tools'), - - // Developer mode toggle (to disable) + if (prefs.ignoreCarpeater) + ListTile( + leading: const SizedBox(width: 24), + title: const Text('CARpeater ID'), + subtitle: Text(prefs.ignoreRepeaterId != null + ? '0x${prefs.ignoreRepeaterId}' + : 'Not set'), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + ), SwitchListTile( - secondary: const Icon(Icons.developer_mode), - title: const Text('Developer Mode'), - subtitle: const Text('Disable to hide developer tools'), - value: appState.developerModeEnabled, - onChanged: (value) { - appState.setDeveloperMode(value); + secondary: const Icon(Icons.shield_outlined), + title: const Text('Disable RSSI Filter'), + subtitle: Text(prefs.disableRssiFilter + ? 'Allows all signal strengths' + : 'Drops signals stronger than -30 dBm'), + value: prefs.disableRssiFilter, + onChanged: isAutoMode ? null : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); + } }, ), + ]), - // GPS Simulator Toggle - SwitchListTile( - secondary: Icon( - Icons.gps_fixed, - color: appState.isGpsSimulatorEnabled ? Colors.orange : null, - ), - title: Row( - children: [ - const Text('GPS Simulator'), - if (appState.isGpsSimulatorEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'SIMULATED', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + // Radio Settings + _buildSection(context, 'Radio', [ + ListTile( + leading: const Icon(Icons.linear_scale), + title: Row( + children: [ + const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showHopBytesInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], - ], - ), - subtitle: Text(appState.isGpsSimulatorEnabled - ? 'Smooth simulated movement active' - : 'Use simulated GPS for testing'), - value: appState.isGpsSimulatorEnabled, - onChanged: (value) { - if (value) { - appState.enableGpsSimulator(); - } else { - appState.disableGpsSimulator(); - } - }, - ), - - // Simulator Settings (only when enabled) - if (appState.isGpsSimulatorEnabled) ...[ - // Speed Slider - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Simulation Speed'), - subtitle: Slider( - value: appState.gpsSimulatorSpeed, - min: 10, - max: 120, - divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - onChanged: (value) { - appState.setGpsSimulatorSpeed(value); - }, ), - trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - style: const TextStyle(fontWeight: FontWeight.bold), + subtitle: appState.enforceHopBytes + ? const Text( + 'Set by Regional Admin — larger IDs reduce collisions in your region.', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in TX/RX path hops'), + trailing: DropdownButton( + value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 3, child: Text('3')), + ], + onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setHopBytes(value); + }, ), ), - - // Pattern Selector ListTile( - leading: const SizedBox(width: 24), - title: const Text('Movement Pattern'), - trailing: SizedBox( - width: 180, - child: DropdownButton( - value: appState.gpsSimulatorPattern, - underline: const SizedBox(), - isExpanded: true, - items: [ - const DropdownMenuItem( - value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), - ), - const DropdownMenuItem( - value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), + leading: const Icon(Icons.gps_fixed), + title: Row( + children: [ + const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showTraceBytesInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - const DropdownMenuItem( - value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + subtitle: !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in trace path'), + trailing: DropdownButton( + value: appState.traceHopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 4, child: Text('4')), + ], + onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setTraceHopBytes(value); + }, + ), + ), + SwitchListTile( + secondary: const Icon(Icons.delete_sweep), + title: Row( + children: [ + const Flexible(child: Text('Delete Channel on Disconnect')), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showDeleteChannelInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - if (appState.hasSimulatorRoute) - DropdownMenuItem( - value: SimulatorPattern.route, - child: Text( - 'Route: ${appState.simulatorRouteName ?? "Loaded"}', - overflow: TextOverflow.ellipsis, - ), - ), - ], - onChanged: (pattern) { - if (pattern != null) { - appState.setGpsSimulatorPattern(pattern); - } - }, - ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], ), + subtitle: Text(prefs.deleteChannelOnDisconnect + ? 'Removes #wardriving channel from device' + : 'Keeps #wardriving channel on device'), + value: prefs.deleteChannelOnDisconnect, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + }, ), + ]), - // Load Route File + // Data Management + _buildSection(context, 'Data', [ ListTile( - leading: const SizedBox(width: 24), - title: const Text('Load Route File'), - subtitle: Text(appState.hasSimulatorRoute - ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' - : 'KML or GPX file'), + leading: const Icon(Icons.cloud_queue), + title: const Text('Queued Pings'), + subtitle: Text('${appState.queueSize} items waiting'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.folder_open), - onPressed: () => _pickRouteFile(context, appState), - tooltip: 'Load route file', + icon: const Icon(Icons.cloud_upload), + onPressed: appState.queueSize > 0 + ? () => appState.forceUploadQueue() + : null, + tooltip: 'Force upload', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: appState.queueSize > 0 + ? () => _confirmClearQueue(context, appState) + : null, + tooltip: 'Clear queue', ), - if (appState.hasSimulatorRoute) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - appState.clearSimulatorRoute(); - AppToast.info(context, 'Route cleared'); - }, - tooltip: 'Clear route', - ), ], ), ), - - // Reset Position Button ListTile( - leading: const SizedBox(width: 24), - title: const Text('Reset Position'), - subtitle: Text(appState.hasSimulatorRoute - ? 'Reset to route start' - : 'Reset to Ottawa downtown'), - trailing: IconButton( - icon: const Icon(Icons.restart_alt), - onPressed: () { - appState.resetGpsSimulator(); - AppToast.info( - context, - appState.hasSimulatorRoute - ? 'Reset to route start' - : 'GPS simulator reset to Ottawa downtown', - ); - }, - ), + leading: const Icon(Icons.delete_sweep), + title: const Text('Clear Map Markers'), + subtitle: const Text('Remove all TX/RX markers from map'), + onTap: () => _confirmClearPings(context, appState), ), - ], - ], // Close developerModeEnabled conditional - - // Debug section (always visible on mobile) - if (!kIsWeb) ...[ - const Divider(), + ]), - _buildSectionHeader(context, 'Debug'), + // Offline Sessions + _buildSection(context, 'Offline Sessions', [ + if (appState.offlineSessions.isEmpty) + ListTile( + leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), + title: Text( + 'No offline sessions stored', + style: TextStyle(color: Colors.grey.shade500), + ), + subtitle: Text( + 'Sessions recorded in offline mode will appear here', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + ), + ) + else + ...appState.offlineSessions.map((session) => _OfflineSessionTile( + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession(context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), + onDownload: () => _downloadOfflineSession(context, appState, session.filename), + )), + ]), + + // About + _buildSection(context, 'About', [ + const ListTile( + leading: Icon(Icons.info_outline), + title: Text(AppConstants.appName), + subtitle: Text('Mesh network coverage mapper'), + ), + ListTile( + leading: const Icon(Icons.new_releases_outlined), + title: const Text('Version'), + subtitle: Text(AppConstants.appVersion), + onTap: () => _onVersionTap(appState), + ), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: const Text('Submit Feedback'), + subtitle: const Text('Report bugs or request features'), + onTap: () => _showBugReportDialog(context, appState), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.github), + title: const Text('GitHub'), + subtitle: const Text('View issues and source code'), + onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.discord), + title: const Text('Discord'), + subtitle: const Text('Join our community chat'), + onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), + ), + ListTile( + leading: const Icon(Icons.groups), + title: const Text('Community'), + subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + onTap: () => _launchUrl('https://ottawamesh.ca/'), + ), + ListTile( + leading: const Icon(Icons.coffee), + title: const Text('Buy us a coffee'), + subtitle: const Text('Support MeshMapper development'), + onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), + ), + ]), - SwitchListTile( - secondary: Icon( - Icons.bug_report, - color: appState.debugLogsEnabled ? Colors.orange : null, + // Exit Options (Android only) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + _buildSection(context, 'Exit', [ + SwitchListTile( + secondary: const Icon(Icons.exit_to_app), + title: const Text('Close App After Disconnect'), + subtitle: const Text('Automatically exit the app when disconnecting'), + value: prefs.closeAppAfterDisconnect, + onChanged: (value) => appState.setCloseAppAfterDisconnect(value), ), - title: Row( - children: [ - const Text('Debug Logs'), - if (appState.debugLogsEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LOGGING', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, + ListTile( + leading: const Icon(Icons.power_settings_new, color: Colors.red), + title: const Text('Close App'), + subtitle: const Text('Exit the app completely'), + onTap: () => _showCloseAppConfirmation(context, appState), + ), + ]), + + // Developer Tools - only visible when developer mode is enabled + if (appState.developerModeEnabled) + _buildSection(context, 'Developer Tools', [ + SwitchListTile( + secondary: const Icon(Icons.developer_mode), + title: const Text('Developer Mode'), + subtitle: const Text('Disable to hide developer tools'), + value: appState.developerModeEnabled, + onChanged: (value) { + appState.setDeveloperMode(value); + }, + ), + SwitchListTile( + secondary: Icon( + Icons.gps_fixed, + color: appState.isGpsSimulatorEnabled ? Colors.orange : null, + ), + title: Row( + children: [ + const Text('GPS Simulator'), + if (appState.isGpsSimulatorEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'SIMULATED', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), - ), + ], ], - ], - ), - subtitle: Text( - appState.debugLogsEnabled - ? AppConstants.isDevelopmentBuild - ? 'Auto-enabled for development build' - : 'Writing logs to file' - : 'Enable to save debug logs to device', + ), + subtitle: Text(appState.isGpsSimulatorEnabled + ? 'Smooth simulated movement active' + : 'Use simulated GPS for testing'), + value: appState.isGpsSimulatorEnabled, + onChanged: (value) { + if (value) { + appState.enableGpsSimulator(); + } else { + appState.disableGpsSimulator(); + } + }, ), - value: appState.debugLogsEnabled, - onChanged: (value) async { - if (value) { - await appState.enableDebugLogs(); - } else { - await appState.disableDebugLogs(); - } - }, - ), + if (appState.isGpsSimulatorEnabled) ...[ + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Simulation Speed'), + subtitle: Slider( + value: appState.gpsSimulatorSpeed, + min: 10, + max: 120, + divisions: 11, + label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + onChanged: (value) { + appState.setGpsSimulatorSpeed(value); + }, + ), + trailing: Text( + formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Movement Pattern'), + trailing: SizedBox( + width: 180, + child: DropdownButton( + value: appState.gpsSimulatorPattern, + underline: const SizedBox(), + isExpanded: true, + items: [ + const DropdownMenuItem( + value: SimulatorPattern.straight, + child: Text('Straight Line', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.circle, + child: Text('Circle', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.randomWalk, + child: Text('Random Walk', overflow: TextOverflow.ellipsis), + ), + if (appState.hasSimulatorRoute) + DropdownMenuItem( + value: SimulatorPattern.route, + child: Text( + 'Route: ${appState.simulatorRouteName ?? "Loaded"}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (pattern) { + if (pattern != null) { + appState.setGpsSimulatorPattern(pattern); + } + }, + ), + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Load Route File'), + subtitle: Text(appState.hasSimulatorRoute + ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' + : 'KML or GPX file'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.folder_open), + onPressed: () => _pickRouteFile(context, appState), + tooltip: 'Load route file', + ), + if (appState.hasSimulatorRoute) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + appState.clearSimulatorRoute(); + AppToast.info(context, 'Route cleared'); + }, + tooltip: 'Clear route', + ), + ], + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Reset Position'), + subtitle: Text(appState.hasSimulatorRoute + ? 'Reset to route start' + : 'Reset to Ottawa downtown'), + trailing: IconButton( + icon: const Icon(Icons.restart_alt), + onPressed: () { + appState.resetGpsSimulator(); + AppToast.info( + context, + appState.hasSimulatorRoute + ? 'Reset to route start' + : 'GPS simulator reset to Ottawa downtown', + ); + }, + ), + ), + ], + ]), - // Log Files List (show when toggle is ON or when files exist) - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ - // Section header with Delete All button - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( + // Debug section (always visible on mobile) + if (!kIsWeb) + _buildSection(context, 'Debug', [ + SwitchListTile( + secondary: Icon( + Icons.bug_report, + color: appState.debugLogsEnabled ? Colors.orange : null, + ), + title: Row( children: [ - Text( - 'Debug Log Files', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Colors.grey.shade600, + const Text('Debug Logs'), + if (appState.debugLogsEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LOGGING', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, ), - ), - const Spacer(), - if (appState.debugLogFiles.isNotEmpty) - TextButton.icon( - icon: const Icon(Icons.delete_sweep, size: 18), - label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + ), ), + ], ], ), - ), - - // Log files list or empty message - if (appState.debugLogFiles.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), - ), - ) - else - ...appState.debugLogFiles.asMap().entries.map((entry) { - final index = entry.key; - final file = entry.value; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isUploading = _uploadingFilePath == file.path; - final wasUploaded = _uploadedFiles.contains(file.path); - // First file (index 0) is the most recent/active log - can't upload - final isCurrentLog = index == 0; - // Parse unix timestamp from filename (meshmapper-debug-{timestamp}.txt) - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); - final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) - : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; - - // Format size and show part count for oversized files - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; + subtitle: Text( + appState.debugLogsEnabled + ? 'Writing logs to file' + : 'Enable to save debug logs to device', + ), + value: appState.debugLogsEnabled, + onChanged: (value) async { + if (value) { + await appState.enableDebugLogs(); } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - if (isCurrentLog) { - sizeDisplay = '$sizeDisplay (current)'; + await appState.disableDebugLogs(); } - - return ListTile( - leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), - subtitle: Text( - sizeDisplay, - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Upload button - if (wasUploaded) - Container( - width: 40, - height: 40, - alignment: Alignment.center, - child: const Icon( - Icons.check_circle, - size: 20, - color: Colors.green, - ), - ) - else if (isUploading) - Container( - width: 40, - height: 40, - alignment: Alignment.center, - child: const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + }, + ), + if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Text( + 'Log Files', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade600, ), - ) - else - IconButton( - icon: const Icon(Icons.cloud_upload, size: 20), - onPressed: _uploadingFilePath != null - ? null - : () => isCurrentLog - ? _uploadCurrentLogFile(appState, file) - : _uploadSingleLogFile(appState, file), - tooltip: isCurrentLog - ? 'Close log and upload' - : 'Upload to developer', - ), - // View button - IconButton( - icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), - tooltip: 'View', + ), + const Spacer(), + if (appState.debugLogFiles.isNotEmpty) ...[ + TextButton.icon( + icon: const Icon(Icons.cloud_upload, size: 18), + label: const Text('Upload'), + onPressed: () => _showUploadLogsDialog(context, appState), ), - // Share button - IconButton( - icon: const Icon(Icons.share, size: 20), - onPressed: () => appState.shareDebugLog(file), - tooltip: 'Share', + TextButton.icon( + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + onPressed: () => _confirmDeleteAllLogs(context, appState), ), ], + ], + ), + ), + if (appState.debugLogFiles.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'No debug logs yet', + style: TextStyle(color: Colors.grey.shade500, fontSize: 13), ), - ); - }), - ], - ], + ) + else + ...appState.debugLogFiles.asMap().entries.map((entry) { + final index = entry.key; + final file = entry.value; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isCurrentLog = index == 0; + final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final fileDate = timestampMatch != null + ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + : null; + final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + + String sizeDisplay; + final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + if (isCurrentLog) { + sizeDisplay = '$sizeDisplay (current)'; + } - const SizedBox(height: 32), + return ListTile( + leading: const Icon(Icons.description, size: 20), + title: Text(dateStr, style: const TextStyle(fontSize: 13)), + subtitle: Text( + sizeDisplay, + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + onPressed: () => _showLogViewer(context, appState, file), + tooltip: 'View', + ), + IconButton( + icon: const Icon(Icons.share, size: 20), + onPressed: () => appState.shareDebugLog(file), + tooltip: 'Share', + ), + ], + ), + ); + }), + ], + ]), ], ), ); } - Widget _buildSectionHeader(BuildContext context, String title) { + Widget _buildSection(BuildContext context, String title, List children) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, + padding: const EdgeInsets.only(bottom: 8), + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), ), + ...children, + const SizedBox(height: 4), + ], + ), ), ); } @@ -846,13 +947,104 @@ class _SettingsScreenState extends State { } } - void _confirmClearQueue(BuildContext context, AppStateProvider appState) { + void _confirmClearQueue(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Queue?'), + content: Text( + 'This will permanently delete ${appState.queueSize} queued pings that haven\'t been uploaded yet.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.clearQueue(); + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + + void _confirmClearPings(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Map Markers?'), + content: const Text( + 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.clearPings(); + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + + void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Disable RSSI Filter?'), + content: const Text( + 'By disabling this filter, you are confirming that you are not operating ' + 'a carpeater (a repeater co-located with your device).\n\n' + 'If this filter is disabled while a carpeater is present, your device will ' + 'report false coverage data to the MeshMapper community map. This degrades ' + 'map accuracy for everyone.\n\n' + 'Only disable this if you are certain no co-located repeater is within range.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.updatePreferences( + appState.preferences.copyWith(disableRssiFilter: true), + ); + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Disable Filter'), + ), + ], + ), + ); + } + + void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + final isConnected = appState.connectionStatus == ConnectionStatus.connected; showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Clear Queue?'), + title: const Text('Enable Anonymous Mode?'), content: Text( - 'This will permanently delete ${appState.queueSize} queued pings that haven\'t been uploaded yet.', + 'Your device will be renamed to "Anonymous" for all mesh pings. ' + 'Other mesh users will not see your companion name.\n\n' + 'Your public key is still used to authenticate your session, but ' + 'neither your sessions nor your pings are linked to it on the server.\n\n' + '${isConnected ? 'Your device will disconnect and reconnect automatically.\n\n' : ''}' + 'If the app crashes or BLE disconnects unexpectedly, your device ' + 'may remain named "Anonymous" until you reconnect and properly disconnect. ' + 'Always use the Disconnect button to restore your device name.', ), actions: [ TextButton( @@ -861,23 +1053,24 @@ class _SettingsScreenState extends State { ), TextButton( onPressed: () { - appState.clearQueue(); Navigator.pop(context); + appState.setAnonymousMode(true); }, - child: const Text('Clear'), + style: TextButton.styleFrom(foregroundColor: Colors.orange), + child: const Text('Enable'), ), ], ), ); } - void _confirmClearPings(BuildContext context, AppStateProvider appState) { + void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Clear Map Markers?'), + title: const Text('Disable Anonymous Mode?'), content: const Text( - 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', + 'This will disconnect and reconnect your device to restore your companion name. Continue?', ), actions: [ TextButton( @@ -886,10 +1079,10 @@ class _SettingsScreenState extends State { ), TextButton( onPressed: () { - appState.clearPings(); Navigator.pop(context); + appState.setAnonymousMode(false); }, - child: const Text('Clear'), + child: const Text('Continue'), ), ], ), @@ -951,8 +1144,232 @@ class _SettingsScreenState extends State { ); } + void _showDiscDropInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.signal_wifi_off, size: 24), + SizedBox(width: 8), + Text('Discovery Drop'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.signal_wifi_off, size: 24), + SizedBox(width: 8), + Text('Discovery Drop'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + appState.updatePreferences( + appState.preferences.copyWith(discDropEnabled: true), + ); + }, + child: const Text('Enable'), + ), + ], + ), + ); + } + + void _showDeleteChannelInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.delete_sweep, size: 24), + SizedBox(width: 8), + Flexible(child: Text('Delete Channel on Disconnect')), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, the #wardriving channel is removed from your radio when you disconnect. ' + 'This keeps your radio\'s channel list clean.\n\n' + 'When disabled, the channel remains on the radio after disconnect.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'If the app crashes or BLE disconnects unexpectedly, your device ' + 'may retain the #wardriving channel until you reconnect and properly disconnect.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showHopBytesInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.linear_scale, size: 24), + SizedBox(width: 8), + Text('TX Bytes'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls how many bytes are used to identify each repeater in TX/RX packet paths. ' + 'More bytes = more unique IDs, reducing collisions in large networks.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + '\u2022 1 byte: 256 unique IDs (default)\n' + '\u2022 2 bytes: 65,536 unique IDs\n' + '\u2022 3 bytes: 16 million unique IDs', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + 'Requires MeshCore firmware v1.14.0+. ' + 'RX always auto-detects the sender\'s byte size.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showTraceBytesInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.gps_fixed, size: 24), + SizedBox(width: 8), + Text('Trace Bytes'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls how many bytes are used for the repeater ID in trace path requests. ' + 'This is separate from TX Bytes because traces use a different encoding.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'TX/RX uses a simple counter:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 3 bytes\n\n' + 'Trace uses bitshift encoding:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 4 bytes', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + '3-byte traces are not supported by the MeshCore protocol. ' + 'When your region uses 3-byte TX paths, set Trace Bytes to 4.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + void _showIntervalSelector(BuildContext context, AppStateProvider appState) { - final currentInterval = appState.preferences.autoPingInterval; + final minInterval = appState.minModeInterval; + var currentInterval = appState.preferences.autoPingInterval; + + // Auto-bump if current interval is below the admin minimum + if (currentInterval < minInterval) { + currentInterval = minInterval; + appState.updatePreferences( + appState.preferences.copyWith(autoPingInterval: minInterval), + ); + } showDialog( context: context, @@ -971,18 +1388,46 @@ class _SettingsScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: AutoPingInterval.values.map((interval) { - final isSelected = interval == currentInterval; - - return RadioListTile( - title: Text('$interval seconds'), - subtitle: Text(interval == 15 - ? 'Fast (More coverage, causes more mesh load)' - : interval == 30 - ? 'Normal (Balanced coverage and mesh load)' - : 'Slow (Less coverage, little mesh load)'), + final isDisabled = interval < minInterval; + + String description; + if (interval == 15) { + description = 'Fast (More coverage, causes more mesh load)'; + } else if (interval == 30) { + description = 'Normal (Balanced coverage and mesh load)'; + } else { + description = 'Slow (Less coverage, little mesh load)'; + } + + final tile = RadioListTile( + title: Text( + '$interval seconds', + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), + subtitle: isDisabled + ? const Text( + 'Set by Regional Admin — slower intervals reduce congestion in your region', + style: TextStyle(fontSize: 12, color: Colors.amber), + ) + : Text( + description, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), value: interval, - selected: isSelected, ); + + if (isDisabled) { + return IgnorePointer( + child: Opacity( + opacity: 0.5, + child: tile, + ), + ); + } + return tile; }).toList(), ), ), @@ -996,7 +1441,49 @@ class _SettingsScreenState extends State { ); } + void _showDistanceSelector(BuildContext context, AppStateProvider appState) { + final currentDistance = appState.preferences.minPingDistanceMeters; + final controller = TextEditingController(text: currentDistance.toString()); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Min Ping Distance'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + suffixText: 'meters', + helperText: 'Minimum ${MinPingDistance.min}m', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final value = int.tryParse(controller.text); + if (value != null && value >= MinPingDistance.min) { + appState.updatePreferences( + appState.preferences.copyWith(minPingDistanceMeters: value), + ); + Navigator.pop(context); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } + void _showRepeaterIdDialog(BuildContext context, AppStateProvider appState) { + const maxHexChars = 6; + const hintText = 'FFFFFF'; + final controller = TextEditingController( text: appState.preferences.ignoreRepeaterId ?? '', ); @@ -1004,22 +1491,22 @@ class _SettingsScreenState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Ignore Repeater ID'), + title: const Text('CARpeater Repeater ID'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Enter the repeater ID to ignore (2 hex digits):'), + const Text('Enter the full 3-byte repeater ID (6 hex digits):'), const SizedBox(height: 16), TextField( controller: controller, decoration: const InputDecoration( - labelText: 'Repeater ID', - hintText: 'FF', + labelText: 'CARpeater ID', + hintText: hintText, prefixText: '0x', border: OutlineInputBorder(), ), - maxLength: 2, + maxLength: maxHexChars, textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters @@ -1034,7 +1521,9 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Enter 2-character hex ID (e.g., FF) to ignore a specific repeater.\nLeave empty to disable.', + 'Enter all 6 hex digits of your CARpeater\'s ID. ' + 'The app will automatically truncate to match your region\'s hop byte size (1, 2, or 3 bytes). ' + 'Multi-hop packets through your CARpeater will be stripped to report the underlying repeater.', style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -1051,7 +1540,8 @@ class _SettingsScreenState extends State { onPressed: () { final value = controller.text.trim().toUpperCase(); final isValidHex = value.isEmpty || - (value.length == 2 && RegExp(r'^[0-9A-F]{2}$').hasMatch(value)); + (value.length == maxHexChars && + RegExp(r'^[0-9A-F]+$').hasMatch(value)); if (isValidHex) { // Enable ignoreCarpeater when setting a repeater ID @@ -1064,7 +1554,7 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Invalid hex value. Use 2 digits (00-FF).'); + AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), @@ -1150,31 +1640,61 @@ class _SettingsScreenState extends State { } Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { - // Show loading indicator - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ), - SizedBox(width: 12), - Text('Uploading session...'), - ], + // Progress text notifier for updating dialog without rebuilding screen + final progressNotifier = ValueNotifier('Authenticating...'); + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text( + 'Uploading session...', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + filename, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (_, status, __) => Text( + status, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ], + ), ), - duration: Duration(seconds: 30), // Will be dismissed when upload completes ), ); - final result = await appState.uploadOfflineSessionWithAuth(filename); + final result = await appState.uploadOfflineSessionWithAuth( + filename, + onProgress: (status) => progressNotifier.value = status, + ); + // Close progress dialog if (context.mounted) { - // Dismiss loading indicator - ScaffoldMessenger.of(context).hideCurrentSnackBar(); + Navigator.of(context).pop(); + } - // Show result + progressNotifier.dispose(); + + if (context.mounted) { + // Show result via SnackBar String message; Color backgroundColor; @@ -1199,6 +1719,10 @@ class _SettingsScreenState extends State { message = 'Partial upload - some pings failed'; backgroundColor = Colors.orange; break; + case OfflineUploadResult.uploadInProgress: + message = 'Another upload is already in progress'; + backgroundColor = Colors.orange; + break; } ScaffoldMessenger.of(context).showSnackBar( @@ -1511,29 +2035,7 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Icons.location_on, color: _hasAlwaysPermission ? Colors.green : null, ), - title: Row( - children: [ - const Text('Background Location'), - if (_hasAlwaysPermission) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'ALWAYS', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), + title: const Text('Background Location'), subtitle: Text( _hasAlwaysPermission ? 'Location tracking works in background' @@ -1557,12 +2059,14 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> /// Widget for displaying an offline session in the list class _OfflineSessionTile extends StatelessWidget { final OfflineSession session; + final bool uploadEnabled; final VoidCallback onUpload; final VoidCallback onDelete; final VoidCallback onDownload; const _OfflineSessionTile({ required this.session, + this.uploadEnabled = true, required this.onUpload, required this.onDelete, required this.onDownload, @@ -1609,9 +2113,9 @@ class _OfflineSessionTile extends StatelessWidget { if (!isUploaded) IconButton( icon: const Icon(Icons.cloud_upload), - onPressed: onUpload, + onPressed: uploadEnabled ? onUpload : null, tooltip: 'Upload session', - color: Colors.green, + color: uploadEnabled ? Colors.green : Colors.grey, ), // Delete button - always available IconButton( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index d55fdca..2cb45fa 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -25,9 +25,13 @@ class ApiQueueService { final ApiService _apiService; Box? _box; Timer? _batchTimer; + Timer? _pingFlushTimer; bool _isUploading = false; bool _isRecovering = false; + // In-memory fallback when Hive is corrupted/unavailable + final List _memoryQueue = []; + // Offline mode bool offlineMode = false; final List> _offlinePings = []; @@ -78,6 +82,7 @@ class ApiQueueService { debugError('[API QUEUE] Failed to clear stale items: $e - recovering'); await _recoverBox(); } + _memoryQueue.clear(); _rxBuffer.clear(); _offlinePings.clear(); @@ -206,8 +211,8 @@ class ApiQueueService { } } - /// Get current queue size - int get queueSize => _safeRead((box) => box.length, 0); + /// Get current queue size (Hive + in-memory fallback) + int get queueSize => _safeRead((box) => box.length, 0) + _memoryQueue.length; /// Enqueue a TX ping /// heardRepeats format: "4e(12.25),77(12.25)" or "None" @@ -235,10 +240,20 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); - _checkBatchUpload(); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); } /// Enqueue an RX observation @@ -316,10 +331,106 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } + onQueueUpdated?.call(queueSize); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); + } + + /// Enqueue a TRACE ping result (targeted zero-hop trace) + Future enqueueTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) async { + final item = ApiQueueItem.fromTrace( + latitude: latitude, + longitude: longitude, + repeaterId: repeaterId, + localSnr: localSnr, + localRssi: localRssi, + remoteSnr: remoteSnr, + timestamp: timestamp, + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + + // In offline mode, accumulate to offline pings list instead of queue + if (offlineMode) { + _offlinePings.add(item.toApiJson()); + debugLog('[API QUEUE] TRACE enqueued (offline): $repeaterId'); + return; + } + + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); - _checkBatchUpload(); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); + } + + /// Enqueue a failed DISC discovery (no nodes responded) + Future enqueueDiscDrop({ + required double latitude, + required double longitude, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + }) async { + final item = ApiQueueItem.fromDiscDrop( + latitude: latitude, + longitude: longitude, + timestamp: timestamp, + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + ); + + // In offline mode, accumulate to offline pings list instead of queue + if (offlineMode) { + _offlinePings.add(item.toApiJson()); + debugLog('[API QUEUE] DISC drop enqueued (offline)'); + return; + } + + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog('[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + } + onQueueUpdated?.call(queueSize); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); } // Guard to prevent concurrent RX buffer flushes @@ -341,10 +452,12 @@ class ApiQueueService { final bufferSize = _rxBuffer.length; _rxBuffer.clear(); - // Now add items to the box + // Now add items to the box (or memory fallback) for (final item in itemsToFlush) { final ok = await _safeWrite((box) => box.add(item)); - if (!ok) break; + if (!ok) { + _memoryQueue.add(item); + } } debugLog('[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); @@ -373,25 +486,23 @@ class ApiQueueService { }); } - void _checkBatchUpload() { - if (queueSize >= _batchSize) { - _uploadBatch(); - } - } - /// Manually flush queue (called by TX-triggered flush timer) Future flushQueue() async { await _flushRxBuffer(); await _uploadBatch(); } - /// Upload batch of queued items + /// Upload batch of queued items (from Hive box or in-memory fallback) Future _uploadBatch() async { if (_isUploading) { debugLog('[API QUEUE] Upload skipped: already uploading'); return; } - if (_safeRead((box) => box.isEmpty, true)) { + + final hiveEmpty = _safeRead((box) => box.isEmpty, true); + final memoryEmpty = _memoryQueue.isEmpty; + + if (hiveEmpty && memoryEmpty) { debugLog('[API QUEUE] Upload skipped: queue empty'); return; } @@ -399,15 +510,8 @@ class ApiQueueService { _isUploading = true; try { - // Log if TX items are waiting in hold period - final pendingTx = _safeRead((box) => box.values.where((item) => - item.type == 'TX' && !item.isUploadEligible).length, 0); - if (pendingTx > 0) { - debugLog('[API QUEUE] $pendingTx TX items still in hold period'); - } - - // Get items ready for upload (must pass retry, retry delay, AND upload eligibility checks) - final items = _safeRead((box) => box.values + // Collect items from both Hive and memory queue + final hiveItems = _safeRead((box) => box.values .where((item) => item.retryCount < _maxRetries && item.isReadyForRetry && @@ -415,6 +519,16 @@ class ApiQueueService { .take(_batchSize) .toList(), []); + final memoryItems = _memoryQueue + .where((item) => + item.retryCount < _maxRetries && + item.isReadyForRetry && + item.isUploadEligible) + .take(_batchSize - hiveItems.length) + .toList(); + + final items = [...hiveItems, ...memoryItems]; + if (items.isEmpty) { debugLog('[API QUEUE] Upload skipped: no items ready for upload'); _isUploading = false; @@ -430,30 +544,47 @@ class ApiQueueService { debugLog('[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); } - debugLog('[API QUEUE] Uploading ${items.length} items...'); + final memoryCount = memoryItems.length; + if (memoryCount > 0) { + debugLog('[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); + } else { + debugLog('[API QUEUE] Uploading ${items.length} items...'); + } // Attempt upload final result = await _apiService.uploadBatch(pings); if (result == UploadResult.success) { final uploadedCount = items.length; - // Remove successful items - for (final item in items) { - await item.delete(); + // Remove successful Hive items + for (final item in hiveItems) { + try { await item.delete(); } catch (_) {} + } + // Remove successful memory items + for (final item in memoryItems) { + _memoryQueue.remove(item); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); onUploadSuccess?.call(uploadedCount); } else if (result == UploadResult.nonRetryable) { - // Data is permanently invalid (bad GPS, invalid request, etc.) — discard - for (final item in items) { - await item.delete(); + // Data is permanently invalid — discard + for (final item in hiveItems) { + try { await item.delete(); } catch (_) {} + } + for (final item in memoryItems) { + _memoryQueue.remove(item); } debugWarn('[API QUEUE] Discarded ${items.length} items (non-retryable error)'); } else { // Mark items as retried - for (final item in items) { + for (final item in hiveItems) { item.markRetried(); } + // Memory items: update retry fields directly (no Hive save) + for (final item in memoryItems) { + item.retryCount++; + item.lastRetryAt = DateTime.now(); + } debugLog('[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); } @@ -472,44 +603,18 @@ class ApiQueueService { await _uploadBatch(); } - /// Force upload after waiting for any TX items in hold period + /// Force upload all queued items immediately /// Used during BLE disconnect to ensure all data is uploaded before session release Future forceUploadWithHoldWait() async { + _pingFlushTimer?.cancel(); await _flushRxBuffer(); - - // Check if any TX items are still in hold period - try { - if (_box != null && _box!.isNotEmpty) { - final now = DateTime.now().millisecondsSinceEpoch; - int maxWaitMs = 0; - - for (final item in _box!.values) { - if (item.type == 'TX' && !item.isUploadEligible) { - final waitMs = item.canUploadAfter - now; - if (waitMs > maxWaitMs) { - maxWaitMs = waitMs; - } - } - } - - if (maxWaitMs > 0) { - // Cap wait time at 6 seconds (slightly more than 5s hold period) - final cappedWaitMs = maxWaitMs.clamp(0, 6000); - debugLog('[API QUEUE] Waiting ${cappedWaitMs}ms for TX hold period to expire'); - await Future.delayed(Duration(milliseconds: cappedWaitMs)); - } - } - } catch (e) { - debugError('[API QUEUE] Failed to check hold period: $e - skipping wait'); - await _recoverBox(); - } - await _uploadBatch(); } /// Clear all queued items Future clear() async { await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -518,16 +623,19 @@ class ApiQueueService { /// Called when device disconnects to ensure no stale pings remain /// Also stops the batch timer to prevent upload attempts without a session Future clearOnDisconnect() async { - // Stop the batch timer to prevent upload attempts without session + // Stop timers to prevent upload attempts without session _batchTimer?.cancel(); _batchTimer = null; - debugLog('[API QUEUE] Batch timer stopped on disconnect'); + _pingFlushTimer?.cancel(); + _pingFlushTimer = null; + debugLog('[API QUEUE] Timers stopped on disconnect'); final count = queueSize + _rxBuffer.length; if (count > 0) { debugLog('[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -541,6 +649,7 @@ class ApiQueueService { debugLog('[API QUEUE] Clearing $count stale items before connect'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); @@ -553,10 +662,18 @@ class ApiQueueService { /// Get failed items (exceeded max retries) List get failedItems { - return _safeRead( + final hiveItems = _safeRead( (box) => box.values.where((item) => item.retryCount >= _maxRetries).toList(), [], ); + final memoryItems = _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); + return [...hiveItems, ...memoryItems]; + } + + /// Get a snapshot of accumulated offline pings without clearing. + /// Used for periodic auto-saves to persist data without losing the in-memory accumulator. + List> getOfflinePingsSnapshot() { + return List>.from(_offlinePings); } /// Get accumulated offline pings and clear the accumulator @@ -572,9 +689,28 @@ class ApiQueueService { _offlinePings.clear(); } + /// Extract all queued items as API JSON without clearing the queue. + /// Used to preserve data before session-expiry disconnect. + Future>> extractAllAsJson() async { + // Flush RX buffer first so all items are in the main queue + await _flushRxBuffer(); + + final hiveItems = _safeRead( + (box) => box.values.toList(), + [], + ); + + final allItems = [...hiveItems, ..._memoryQueue]; + + if (allItems.isEmpty) return []; + + return allItems.map((item) => item.toApiJson()).toList(); + } + /// Dispose of resources void dispose() { _batchTimer?.cancel(); + _pingFlushTimer?.cancel(); _box?.close(); } } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 717b232..064c25a 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:http/http.dart' as http; @@ -38,8 +39,17 @@ class ApiService { bool _rxAllowed = false; int? _sessionExpiresAt; Timer? _heartbeatTimer; + Timer? _heartbeatRetryTimer; + Timer? _sessionDeadlineTimer; + int _heartbeatRetryCount = 0; + static const int _maxHeartbeatRetries = 5; Function? _onSessionExpiring; List _channels = []; + List _scopes = []; + bool _enforceHybrid = false; + bool _enforceDiscDrop = false; + int _minModeInterval = 15; + int _apiHopBytes = 1; /// Callback to get current GPS coordinates for heartbeat /// Returns (lat, lon) or null if GPS is not available @@ -48,6 +58,24 @@ class ApiService { /// Regional channels from auth response (e.g., ['public', 'ottawa', 'testing']) List get channels => List.unmodifiable(_channels); + /// Regional scopes from auth response (e.g., ['ottawa']) + List get scopes => List.unmodifiable(_scopes); + + /// Whether hybrid mode is enforced by regional admin + bool get enforceHybrid => _enforceHybrid; + + /// Whether discovery drop is enforced by regional admin + bool get enforceDiscDrop => _enforceDiscDrop; + + /// Minimum auto-ping interval enforced by regional admin (seconds) + int get minModeInterval => _minModeInterval; + + /// Path hop bytes enforced by regional admin (1, 2, or 3) + int get apiHopBytes => _apiHopBytes; + + /// Whether hop bytes are enforced by regional admin (only 2 or 3 enforces) + bool get enforceHopBytes => _apiHopBytes > 1; + ApiService({http.Client? client}) : _client = client ?? http.Client(); /// Sanitize payload by removing sensitive fields for logging @@ -196,6 +224,8 @@ class ApiService { /// @param publicKey Device public key (for existing auth flow) /// @param contactUri Signed contact URI (for registration flow) /// @param offlineMode Set to true when uploading offline session data + /// @param skipSessionStore When true, does not write to shared _sessionId/_txAllowed/etc. Caller manages session locally. + /// @param sessionId Explicit session ID for disconnect. When provided, disconnect uses this instead of _sessionId and skips _clearSession(). /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, @@ -210,6 +240,8 @@ class ApiService { double? lon, double? accuracyMeters, bool offlineMode = false, + bool skipSessionStore = false, + String? sessionId, }) async { final stopwatch = Stopwatch()..start(); try { @@ -250,8 +282,8 @@ class ApiService { 'timestamp': DateTime.now().millisecondsSinceEpoch ~/ 1000, }; } else { - // For disconnect: add session_id - payload['session_id'] = _sessionId; + // For disconnect: use explicit sessionId if provided, otherwise shared _sessionId + payload['session_id'] = sessionId ?? _sessionId; } final response = await _client.post( @@ -288,25 +320,71 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) if ((reason == 'connect' || reason == 'register') && data['success'] == true) { - _sessionId = data['session_id'] as String?; - _txAllowed = data['tx_allowed'] == true; - _rxAllowed = data['rx_allowed'] == true; - _sessionExpiresAt = data['expires_at'] as int?; - - // Parse channels array from auth response - final channelsData = data['channels']; - if (channelsData is List) { - _channels = channelsData.cast().toList(); - debugLog('[API] Regional channels: $_channels'); - } else { - _channels = []; - } + if (!skipSessionStore) { + _sessionId = data['session_id'] as String?; + _txAllowed = data['tx_allowed'] == true; + _rxAllowed = data['rx_allowed'] == true; + _sessionExpiresAt = data['expires_at'] as int?; + + // Parse channels array from auth response + final channelsData = data['channels']; + if (channelsData is List) { + _channels = channelsData.cast().toList(); + debugLog('[API] Regional channels: $_channels'); + } else { + _channels = []; + } + + // Parse scopes array from auth response + final scopesData = data['scopes']; + if (scopesData is List && scopesData.isNotEmpty) { + _scopes = scopesData.cast().toList(); + debugLog('[API] Regional scopes: $_scopes'); + } else { + _scopes = []; + } + + // Parse enforce_hybrid flag from auth response + _enforceHybrid = data['enforce_hybrid'] == true; + if (_enforceHybrid) { + debugLog('[API] Regional admin enforces hybrid mode'); + } + + // Parse disc_drop flag from auth response + _enforceDiscDrop = data['disc_drop'] == true; + if (_enforceDiscDrop) { + debugLog('[API] Regional admin enforces discovery drop'); + } + + // Parse min_mode_interval from auth response + final minInterval = data['min_mode_interval']; + if (minInterval is int && minInterval > 0) { + _minModeInterval = minInterval; + debugLog('[API] Regional admin min interval: ${_minModeInterval}s'); + } else { + _minModeInterval = 15; + } - // Note: Heartbeat is enabled by AppStateProvider when auto mode starts - // (not on initial auth, since heartbeat is only for auto mode) + // Parse hop_bytes from auth response + final hopBytes = data['hop_bytes']; + if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { + _apiHopBytes = hopBytes; + if (_apiHopBytes > 1) { + debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + } + } else { + _apiHopBytes = 1; + } + + // Note: Heartbeat is enabled by AppStateProvider when auto mode starts + // (not on initial auth, since heartbeat is only for auto mode) + } } else if (reason == 'disconnect') { - // Clear session on disconnect - _clearSession(); + // Only clear shared session when no explicit sessionId was provided + // (explicit sessionId means caller manages its own session lifecycle) + if (sessionId == null) { + _clearSession(); + } } return data; @@ -526,6 +604,11 @@ class ApiService { _gpsProvider = null; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -533,9 +616,12 @@ class ApiService { /// Matches scheduleHeartbeat() in wardrive.js /// @param expiresAt Unix timestamp when session expires void scheduleHeartbeat(int expiresAt) { - // Cancel any existing heartbeat timer + // Cancel any existing heartbeat timer and reset retry state _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; if (!_heartbeatEnabled) return; @@ -548,15 +634,17 @@ class ApiService { // Session is about to expire or already expired - send heartbeat immediately debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); - return; - } + } else { + debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { + debugLog('[HEARTBEAT] Timer fired, sending keepalive'); + _sendScheduledHeartbeat(); + }); + } - _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { - debugLog('[HEARTBEAT] Timer fired, sending keepalive'); - _sendScheduledHeartbeat(); - }); + // Schedule session deadline timer at exact expiry + _scheduleSessionDeadline(expiresAt); } /// Send scheduled heartbeat with GPS coordinates @@ -567,10 +655,22 @@ class ApiService { if (result?['success'] == true) { debugLog('[HEARTBEAT] Heartbeat successful'); + // Reset retry state on success + _heartbeatRetryCount = 0; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; // Next heartbeat will be scheduled when we get new expires_at } else if (result == null) { - // Network error — transient, trigger session expiring - debugWarn('[HEARTBEAT] Heartbeat failed: network error'); + // Network error — schedule retry with exponential backoff + if (_heartbeatRetryCount < _maxHeartbeatRetries) { + final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); + _heartbeatRetryCount++; + debugWarn('[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = Timer(Duration(seconds: delay), _sendScheduledHeartbeat); + } else { + debugError('[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); + } _onSessionExpiring?.call(); } else { // Server returned an error — check if critical @@ -593,15 +693,51 @@ class ApiService { } } - /// Clear session data and cancel heartbeat timer + /// Schedule a hard deadline timer at the exact session expiry time. + /// If the server is unreachable and all heartbeat retries fail, this fires + /// and triggers the same disconnect flow as a server-returned session_expired. + void _scheduleSessionDeadline(int expiresAt) { + _sessionDeadlineTimer?.cancel(); + if (!_heartbeatEnabled) return; + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final secondsUntilExpiry = expiresAt - now; + + if (secondsUntilExpiry <= 0) { + _onSessionDeadlineReached(); + return; + } + + debugLog('[HEARTBEAT] Session deadline set for ${secondsUntilExpiry}s from now'); + _sessionDeadlineTimer = Timer(Duration(seconds: secondsUntilExpiry), _onSessionDeadlineReached); + } + + /// Called when the session deadline timer fires — server was unreachable + void _onSessionDeadlineReached() { + debugError('[HEARTBEAT] Session deadline reached - server unreachable, triggering session expiry'); + _clearSession(); + onSessionError?.call('session_expired', 'Session has timed out (server unreachable)'); + } + + /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; _txAllowed = false; _rxAllowed = false; _sessionExpiresAt = null; _channels = []; + _scopes = []; + _enforceHybrid = false; + _enforceDiscDrop = false; + _minModeInterval = 15; + _apiHopBytes = 1; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; debugLog('[API] Session cleared'); } @@ -682,7 +818,7 @@ class ApiService { /// Returns a list of enabled repeaters for the given IATA zone code Future> fetchRepeaters(String iata) async { final stopwatch = Stopwatch()..start(); - const endpoint = '/repeaters.json'; + const endpoint = '/get_repeaters.php'; try { final url = 'https://${iata.toLowerCase()}.meshmapper.net$endpoint'; @@ -734,10 +870,120 @@ class ApiService { } } + /// Submit wardrive data using an explicit session ID (for offline uploads) + /// Does NOT read/write shared _sessionId, _sessionExpiresAt, or heartbeat state + Future?> submitWardriveDataWithSessionId( + List> entries, + String sessionId, + ) async { + final stopwatch = Stopwatch()..start(); + try { + final payload = { + 'key': apiKey, + 'session_id': sessionId, + 'data': entries, + }; + + final response = await _client.post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ).timeout(const Duration(seconds: 30)); + + stopwatch.stop(); + + if (response.statusCode != 200) { + debugError('[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); + debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + } + + Map data; + try { + data = json.decode(response.body) as Map; + } on FormatException { + debugError('[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' + '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); + rethrow; + } + + final antennaSummary = entries.map((e) => + '${e['type']}:external_antenna=${e['external_antenna']}' + ).join(', '); + _logApiCall( + endpoint: '/wardrive-api.php/wardrive (offline)', + method: 'POST', + stopwatch: stopwatch, + statusCode: response.statusCode, + request: {'data': '${entries.length} items', 'items': antennaSummary}, + response: data, + ); + + // Do NOT update shared _sessionExpiresAt or schedule heartbeat + return data; + } catch (e) { + stopwatch.stop(); + debugError('[API] POST /wardrive-api.php/wardrive (offline) failed: $e'); + return null; + } + } + + /// Upload batch using explicit session ID (for offline uploads) + /// Returns UploadResult only — does NOT call _clearSession(), onSessionError, or onMaintenanceMode + Future uploadBatchWithSessionId( + List> pings, + String sessionId, + ) async { + if (pings.isEmpty) return UploadResult.success; + + try { + final result = await submitWardriveDataWithSessionId(pings, sessionId); + + if (result == null) { + debugError('[API] Offline upload batch failed: no response'); + return UploadResult.retryable; + } + + if (result['success'] == true) { + debugLog('[API] Offline upload batch SUCCESS: ${pings.length} items'); + return UploadResult.success; + } + + final reason = result['reason'] as String?; + + // For offline uploads, session/auth errors are non-retryable but do NOT cascade + const criticalErrors = { + 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', + 'invalid_key', 'unauthorized', 'bad_key', + 'outside_zone', 'zone_full', 'zone_disabled', + }; + if (criticalErrors.contains(reason)) { + debugError('[API] Offline upload batch session error: $reason'); + return UploadResult.nonRetryable; + } + + const nonRetryableErrors = { + 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + }; + if (nonRetryableErrors.contains(reason)) { + debugWarn('[API] Offline upload batch non-retryable error: $reason - discarding batch'); + return UploadResult.nonRetryable; + } + + return UploadResult.retryable; + } catch (e) { + debugError('[API] Offline upload batch exception: $e'); + return UploadResult.retryable; + } + } + /// Dispose of resources void dispose() { _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _sessionDeadlineTimer?.cancel(); + _sessionDeadlineTimer = null; _client.close(); } } diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 49a110c..3ff0d17 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -249,6 +249,8 @@ class BackgroundServiceManager { String body; if (mode == 'Passive Mode') { body = 'RX: $rxCount | Queue: $queueSize'; + } else if (mode == 'Trace Mode') { + body = 'Trace: $txCount | RX: $rxCount | Queue: $queueSize'; } else { body = 'TX: $txCount | RX: $rxCount | Queue: $queueSize'; } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 6131f83..d279477 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -46,6 +46,7 @@ class MobileBluetoothService implements BluetoothService { StreamSubscription? _connectionStateSubscription; StreamSubscription? _notificationSubscription; StreamSubscription? _scanSubscription; + StreamController? _scanController; // Store scanned device info for use in connect() // This preserves the device name from scan results @@ -183,10 +184,15 @@ class MobileBluetoothService implements BluetoothService { @override Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); + _scanController = controller; _updateStatus(ConnectionStatus.scanning); try { + // Ensure any previous scan stream is closed before starting a new one + await _scanSubscription?.cancel(); + _scanSubscription = null; + // Start scanning with filter for MeshCore service UUID await fbp.FlutterBluePlus.startScan( withServices: [fbp.Guid(BleUuids.serviceUuid)], @@ -208,14 +214,30 @@ class MobileBluetoothService implements BluetoothService { ); // Store device info for use in connect() - preserves name from scan _scannedDevices[device.id] = device; - controller.add(device); + if (!controller.isClosed) { + controller.add(device); + } } }); + // Complete stream when scan naturally stops (timeout or platform stop) + unawaited(() async { + await fbp.FlutterBluePlus.isScanning.where((isScanning) => !isScanning).first; + if (!controller.isClosed) { + await controller.close(); + } + }()); + yield* controller.stream; } finally { await _scanSubscription?.cancel(); _scanSubscription = null; + if (!controller.isClosed) { + await controller.close(); + } + if (identical(_scanController, controller)) { + _scanController = null; + } } } @@ -224,6 +246,11 @@ class MobileBluetoothService implements BluetoothService { await fbp.FlutterBluePlus.stopScan(); await _scanSubscription?.cancel(); _scanSubscription = null; + final scanController = _scanController; + if (scanController != null && !scanController.isClosed) { + await scanController.close(); + } + _scanController = null; // NOTE: Do NOT fire 'disconnected' here. Stopping a scan is not a disconnection. // The status will be updated by connect() when a connection starts. // Firing 'disconnected' here causes a race condition where the queued event diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 1535a62..753c67c 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -28,6 +28,18 @@ class GpsService { /// Reference: getValidGpsForZoneCheck() in wardrive.js static const double maxAccuracyMetersForZoneCheck = 50.0; + /// Configured minimum ping distance (user-adjustable, clamped to minDistanceMeters floor) + double _configuredMinDistance = minDistanceMeters; + + /// Get the configured minimum ping distance + double get configuredMinDistance => _configuredMinDistance; + + /// Set the minimum ping distance (clamped to 25m floor) + void setMinPingDistance(double meters) { + _configuredMinDistance = meters < minDistanceMeters ? minDistanceMeters : meters; + debugLog('[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); + } + final _statusController = StreamController.broadcast(); final _positionController = StreamController.broadcast(); @@ -161,6 +173,14 @@ class GpsService { Future startWatching() async { debugLog('[GPS] startWatching() called, current status: $_status'); + // Ensure only one active position stream subscription exists. + // startWatching() can be called multiple times (e.g. after permission flow). + if (_positionSubscription != null) { + debugLog('[GPS] Existing position subscription found, restarting watcher'); + await _positionSubscription?.cancel(); + _positionSubscription = null; + } + // Check if location services are enabled first (system-level setting) final serviceEnabled = await isLocationServiceEnabled(); debugLog('[GPS] Location services check: enabled=$serviceEnabled'); @@ -194,6 +214,12 @@ class GpsService { } debugLog('[GPS] Starting position stream listener...'); + + // Cancel any existing subscription to prevent orphaned listeners + // (e.g. restartGpsAfterPermission() racing with _initialize()) + await _positionSubscription?.cancel(); + _positionSubscription = null; + _updateStatus(GpsStatus.searching); // Configure location settings for position stream @@ -260,10 +286,10 @@ class GpsService { ); } - /// Check if current position is far enough from last ping (25m minimum) + /// Check if current position is far enough from last ping bool canPingAtPosition(Position position) { if (_lastPingPosition == null) return true; - return distanceFromLastPing(position) >= minDistanceMeters; + return distanceFromLastPing(position) >= _configuredMinDistance; } /// Mark current position as ping location diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 0292a28..07c0810 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -17,11 +17,15 @@ class DeviceQueryResponse { final int protocolVersion; final String manufacturer; final String? firmwareBuildDate; // Added in protocol v8 + final String? firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) + final int? pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) const DeviceQueryResponse({ required this.protocolVersion, required this.manufacturer, this.firmwareBuildDate, + this.firmwareVersionString, + this.pathHashMode, }); } @@ -66,6 +70,7 @@ class MeshCoreConnection { final _rawDataController = StreamController>.broadcast(); final _logRxDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); final _controlDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _traceDataController = StreamController.broadcast(); final _noiseFloorController = StreamController.broadcast(); final _batteryController = StreamController.broadcast(); @@ -120,6 +125,10 @@ class MeshCoreConnection { /// Stream of ControlData packets (for discovery responses) Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => _controlDataController.stream; + /// Stream of TraceData packets (for trace path responses) + /// 0x89 has NO snr/rssi prefix — raw bytes are the trace payload directly + Stream get traceDataStream => _traceDataController.stream; + /// Stream of noise floor updates (dBm) Stream get noiseFloorStream => _noiseFloorController.stream; @@ -399,6 +408,9 @@ class MeshCoreConnection { case PushCodes.controlData: _onControlDataPush(reader); break; + case PushCodes.traceData: + _onTraceDataPush(reader); + break; case ResponseCodes.stats: _onStatsResponse(reader); break; @@ -438,17 +450,43 @@ class MeshCoreConnection { // Protocol v7+ format reader.readBytes(6); // skip reserved bytes final buildDate = reader.readCString(12); // e.g. "04-Jan-2026" - final manufacturerModel = reader.readString(); // remainder of frame - + + // Read manufacturer model as CString(40) — fixed-length null-terminated + final manufacturerModel = reader.readCString(40); + + // Parse additional fields from v9+ firmware + int? pathHashMode; + String? firmwareVersionString; + if (reader.remainingBytesCount > 0) { + // FIRMWARE_VERSION: 20-byte null-terminated C-string + if (reader.remainingBytesCount >= 20) { + firmwareVersionString = reader.readCString(20); + debugLog('[CONN] Firmware version string: $firmwareVersionString'); + } + + // client_repeat: 1 byte (v9+, skip) + if (reader.remainingBytesCount >= 1) { + reader.readByte(); // client_repeat + } + + // path_hash_mode: 1 byte (v10+) + if (reader.remainingBytesCount >= 1) { + pathHashMode = reader.readByte(); + debugLog('[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); + } + } + debugLog('[CONN] Build date: $buildDate'); debugLog('[CONN] Manufacturer model: $manufacturerModel'); - + final response = DeviceQueryResponse( protocolVersion: firmwareVer, manufacturer: manufacturerModel, firmwareBuildDate: buildDate, + firmwareVersionString: firmwareVersionString, + pathHashMode: pathHashMode, ); - + _deviceQueryCompleter?.complete(response); _deviceQueryCompleter = null; } else { @@ -584,6 +622,17 @@ class MeshCoreConnection { _controlDataController.add((raw: raw, snr: snr, rssi: rssi)); } + void _onTraceDataPush(BufferReader reader) { + // 0x89 TraceData has NO snr/rssi prefix (unlike 0x88 LogRxData). + // The entire remaining payload is the trace response: + // [reserved][path_len][flags][tag:4][auth:4][path_hashes][path_snrs] + final raw = reader.readRemainingBytes(); + + debugLog('[CONN] Received trace data: ${raw.length} bytes'); + + _traceDataController.add(raw); + } + void _onStatsResponse(BufferReader reader) { // Stats response format (from web client): // @@ -725,6 +774,14 @@ class MeshCoreConnection { await _sendToRadio(data); } + /// Set the companion advertised name + Future setAdvertName(String name) async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setAdvertName); + data.writeString(name); + await _sendToRadio(data); + } + /// Set radio parameters Future setRadioParams(int freq, int bw, int sf, int cr) async { final data = BufferWriter(); @@ -771,6 +828,24 @@ class MeshCoreConnection { await _sendToRadio(data); } + /// Set flood scope for regional packet filtering + /// TransportKey is 16-byte SHA-256 derived key from scope name + Future setFloodScope(Uint8List transportKey) async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setFloodScope); + data.writeByte(0); // reserved byte + data.writeBytes(transportKey); // 16-byte key + await _sendToRadio(data); + } + + /// Clear flood scope (return to unscoped global flood) + Future clearFloodScope() async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setFloodScope); + data.writeByte(0); // reserved byte — no key means clear + await _sendToRadio(data); + } + /// Delete channel by setting it to empty Future deleteChannel(int channelIdx) async { await setChannel(channelIdx, '', Uint8List(16)); @@ -904,6 +979,38 @@ class MeshCoreConnection { return tag; } + /// Send trace path to a specific repeater (targeted ping / zero-hop trace) + /// Returns the 4-byte tag used for matching the response + /// [hopBytes] controls trace ID size: 1, 2, or 4 bytes (bitshift encoding) + Future sendTracePath(Uint8List repeaterIdBytes, {int hopBytes = 1}) async { + final random = Random.secure(); + final tag = Uint8List.fromList([ + random.nextInt(256), random.nextInt(256), + random.nextInt(256), random.nextInt(256), + ]); + + // Trace uses bitshift encoding: actual_bytes = 1 << path_sz + // 1 → path_sz=0, 2 → path_sz=1, 4 → path_sz=2 + final int pathSz; + switch (hopBytes) { + case 4: pathSz = 2; break; + case 2: pathSz = 1; break; + default: pathSz = 0; break; + } + final int flags = pathSz & 0x03; + + debugLog('[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); + + final data = BufferWriter(); + data.writeByte(CommandCodes.sendTracePath); // 0x24 + data.writeBytes(tag); // 4-byte tag + data.writeUInt32LE(0); // auth_code = 0 + data.writeByte(flags); // flags with path_sz in bits 0-1 + data.writeBytes(repeaterIdBytes); // target repeater ID + await _sendToRadio(data); + return tag; + } + /// Get battery voltage Future getBatteryVoltage() async { final data = BufferWriter(); @@ -1028,6 +1135,17 @@ class MeshCoreConnection { debugLog('[CONN] Stopped battery polling'); } + /// Set path hash mode on the radio + /// mode: 0=1-byte, 1=2-byte, 2=3-byte (persisted in radio prefs) + Future setPathHashMode(int mode) async { + final data = BufferWriter(); + data.writeByte(CommandCodes.setPathHashMode); // 61 (0x3D) + data.writeByte(0); // reserved + data.writeByte(mode); // 0=1-byte, 1=2-byte, 2=3-byte + await _sendToRadio(data); + debugLog('[CONN] Sent setPathHashMode: mode=$mode (${mode + 1}-byte hops)'); + } + /// Reboot device Future reboot() async { final data = BufferWriter(); @@ -1046,6 +1164,7 @@ class MeshCoreConnection { _rawDataController.close(); _logRxDataController.close(); _controlDataController.close(); + _traceDataController.close(); _noiseFloorController.close(); _batteryController.close(); } diff --git a/lib/services/meshcore/crypto_service.dart b/lib/services/meshcore/crypto_service.dart index cf736dd..30da886 100644 --- a/lib/services/meshcore/crypto_service.dart +++ b/lib/services/meshcore/crypto_service.dart @@ -55,6 +55,20 @@ class CryptoService { return channelKey; } + /// Derive a 16-byte TransportKey from a scope/region name + /// Same algorithm as channel key derivation: SHA-256(name)[0:16] + /// API returns names without '#' prefix (e.g., "ottawa") — we prepend it + /// to match MeshCore's implicit hashtag region convention + static Uint8List deriveScopeKey(String scopeName) { + final name = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final normalizedName = name.toLowerCase(); + final bytes = utf8.encode(normalizedName); + final digest = sha256.convert(bytes); + final scopeKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); + debugLog('[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); + return scopeKey; + } + /// Get channel key for any channel (handles both Public and hashtag channels) /// /// @param channelName - Channel name (e.g., "Public", "#wardriving", "#testing") diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index a836f3a..5796d6f 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -20,6 +20,9 @@ class DiscTracker { /// Callback to check if a repeater should be ignored (carpeater filter) final bool Function(String repeaterId)? shouldIgnoreRepeater; + /// When true, skip RSSI carpeater check (user setting) + final bool disableRssiFilter; + /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when a discovery response is dropped due to carpeater detection void Function(String repeaterId, String reason)? onCarpeaterDrop; @@ -28,7 +31,10 @@ class DiscTracker { /// Parameters: (node, isNew) - isNew is true for first time seeing this node void Function(DiscoveredNode node, bool isNew)? onNodeDiscovered; - DiscTracker({this.shouldIgnoreRepeater}); + /// Number of bytes per hop in path hash (1, 2, or 3). Controls repeater ID length. + final int hopBytes; + + DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false, this.hopBytes = 1}); /// Callback fired when discovery window completes void Function(List discoveredNodes)? onWindowComplete; @@ -39,7 +45,7 @@ class DiscTracker { /// @param windowDuration - How long to listen (default 7 seconds) void startTracking({ required Uint8List tag, - Duration windowDuration = const Duration(seconds: 7), + Duration windowDuration = const Duration(seconds: 5), }) { debugLog('[DISC] Starting discovery tracking'); debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); @@ -131,8 +137,8 @@ class DiscTracker { final pubkey = rawBytes.sublist(7, 39); final pubkeyHex = pubkey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); - // Get repeater ID (first 2 hex chars = first byte) - final repeaterId = pubkeyHex.substring(0, 2); + // Get repeater ID (first N hex chars based on hopBytes setting) + final repeaterId = pubkeyHex.substring(0, hopBytes * 2); // Check if this repeater should be ignored (user carpeater filter) if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { @@ -141,7 +147,9 @@ class DiscTracker { } // Check RSSI (carpeater failsafe) - if (PacketValidator.isCarpeater(localRssi)) { + if (disableRssiFilter) { + debugLog('[DISC] RSSI filter disabled by user, skipping carpeater check'); + } else if (PacketValidator.isCarpeater(localRssi)) { debugLog('[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater, repeater=$repeaterId'); onCarpeaterDrop?.call(repeaterId, 'RSSI too strong ($localRssi dBm)'); @@ -204,7 +212,7 @@ class DiscTracker { /// Discovered node data class DiscoveredNode { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") + final String repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM final double localSnr; // SNR as seen by local device (dB) final int localRssi; // RSSI as seen by local device (dBm) diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index b3fc743..c65a6ba 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -8,31 +8,31 @@ import 'protocol_constants.dart'; class PacketMetadata { /// Original raw packet bytes final Uint8List raw; - + /// Packet header byte (contains route type, payload type, version) final int header; - + /// Route type (FLOOD=0x01, DIRECT=0x02) final int routeType; - - /// Number of hops in path - final int pathLength; - - /// Raw path bytes (repeater IDs) + + /// Raw pathLen byte (encodes both hash size and hop count) + final int pathLenRaw; + + /// Number of bytes per hop hash (1, 2, 3, or 4) + final int pathHashSize; + + /// Number of hops in path (0-63) + final int pathHashCount; + + /// Raw path bytes (repeater IDs, length = pathHashCount * pathHashSize) final Uint8List pathBytes; - - /// First hop (first repeater ID) - used for TX tracking - final int? firstHop; - - /// Last hop (last repeater ID) - used for RX logging - final int? lastHop; - + /// Signal-to-noise ratio (dB) final double snr; - + /// Received signal strength indicator (dBm) final int rssi; - + /// Encrypted payload (after header + path) final Uint8List encryptedPayload; @@ -40,17 +40,17 @@ class PacketMetadata { required this.raw, required this.header, required this.routeType, - required this.pathLength, + required this.pathLenRaw, + required this.pathHashSize, + required this.pathHashCount, required this.pathBytes, - required this.firstHop, - required this.lastHop, required this.snr, required this.rssi, required this.encryptedPayload, }); /// Parse packet metadata from BLE LogRxData event - /// + /// /// Event structure: /// ```dart /// { @@ -61,21 +61,21 @@ class PacketMetadata { /// ``` factory PacketMetadata.fromLogRxData(Map data) { debugLog('[RX PARSE] Starting metadata parsing'); - + final Uint8List raw = data['raw'] as Uint8List; final double snr = (data['lastSnr'] as num).toDouble(); final int rssi = data['lastRssi'] as int; - + // Dump raw packet for debugging final rawHex = raw.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); debugLog('[RX PARSE] RAW Packet (${raw.length} bytes): $rawHex'); - + // Extract header byte from raw[0] final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - + debugLog('[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); - + // Calculate offset for Path Length based on route type // Reference: wardrive.js lines 3168-3173 int pathLengthOffset = 1; @@ -83,42 +83,44 @@ class PacketMetadata { // TransportFlood or TransportDirect - has 4-byte transport codes pathLengthOffset = 5; } - - // Extract path length from calculated offset - final int pathLength = raw[pathLengthOffset]; - - debugLog('[RX PARSE] Path length offset: $pathLengthOffset, Path length: $pathLength'); - + + // Extract raw pathLen byte and decode multi-byte path encoding + // pathHashSize = (pathLen >> 6) + 1 → 1, 2, 3, or 4 + // pathHashCount = pathLen & 63 → 0-63 hops + final int pathLenRaw = raw[pathLengthOffset]; + final int pathHashSize = (pathLenRaw >> 6) + 1; + final int pathHashCount = pathLenRaw & 63; + final int pathByteLen = pathHashCount * pathHashSize; + + debugLog('[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' + 'pathHashSize: $pathHashSize bytes/hop, pathHashCount: $pathHashCount hops, pathByteLen: $pathByteLen'); + // Path data starts after path length byte final int pathDataOffset = pathLengthOffset + 1; final Uint8List pathBytes = raw.sublist( pathDataOffset, - (pathDataOffset + pathLength).clamp(0, raw.length), + (pathDataOffset + pathByteLen).clamp(0, raw.length), ); - - // Derive first and last hops - final int? firstHop = pathBytes.isNotEmpty ? pathBytes.first : null; - final int? lastHop = pathBytes.isNotEmpty ? pathBytes.last : null; - + // Extract encrypted payload after path data - final int payloadOffset = pathDataOffset + pathLength; + final int payloadOffset = pathDataOffset + pathByteLen; final Uint8List encryptedPayload = raw.sublist(payloadOffset); - + debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' - 'pathLength=$pathLength, ' - 'firstHop=${firstHop != null ? '0x${firstHop.toRadixString(16).padLeft(2, '0')}' : 'null'}, ' - 'lastHop=${lastHop != null ? '0x${lastHop.toRadixString(16).padLeft(2, '0')}' : 'null'}, ' + 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' + 'firstHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(0, pathHashSize)) : 'null'}, ' + 'lastHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(pathBytes.length - pathHashSize)) : 'null'}, ' 'SNR=$snr, RSSI=$rssi, ' 'payload=${encryptedPayload.length} bytes'); - + return PacketMetadata( raw: raw, header: header, routeType: routeType, - pathLength: pathLength, + pathLenRaw: pathLenRaw, + pathHashSize: pathHashSize, + pathHashCount: pathHashCount, pathBytes: pathBytes, - firstHop: firstHop, - lastHop: lastHop, snr: snr, rssi: rssi, encryptedPayload: encryptedPayload, @@ -160,20 +162,49 @@ class PacketMetadata { return payloadType == PayloadType.advert; } + /// Check if packet is TRACE (trace path response, header 0x26) + bool get isTrace { + final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + return payloadType == PayloadType.trace; + } + /// Get first hop as hex string (for TX tracking keys) + /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get firstHopHex { - return firstHop?.toRadixString(16).padLeft(2, '0'); + if (pathHashCount == 0) return null; + return _bytesToHex(pathBytes.sublist(0, pathHashSize)); } /// Get last hop as hex string (for RX logging keys) + /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get lastHopHex { - return lastHop?.toRadixString(16).padLeft(2, '0'); + if (pathHashCount == 0) return null; + return _bytesToHex(pathBytes.sublist(pathBytes.length - pathHashSize)); + } + + /// Get any hop by index as hex string + /// @param hopIndex 0-based hop index (0 = first hop) + String? getHopHex(int hopIndex) { + if (hopIndex < 0 || hopIndex >= pathHashCount) return null; + final offset = hopIndex * pathHashSize; + return _bytesToHex(pathBytes.sublist(offset, offset + pathHashSize)); + } + + /// Convert N bytes to uppercase hex string + String _bytesToHex(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + } + + /// Static version for use in factory constructor + static String _bytesToHexStatic(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); } @override String toString() { return 'PacketMetadata(header=0x${header.toRadixString(16).padLeft(2, '0')}, ' - 'pathLength=$pathLength, firstHop=$firstHopHex, lastHop=$lastHopHex, ' + 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' + 'firstHop=$firstHopHex, lastHop=$lastHopHex, ' 'SNR=$snr, RSSI=$rssi)'; } } diff --git a/lib/services/meshcore/packet_validator.dart b/lib/services/meshcore/packet_validator.dart index 7e9b784..e9cec94 100644 --- a/lib/services/meshcore/packet_validator.dart +++ b/lib/services/meshcore/packet_validator.dart @@ -21,11 +21,15 @@ class PacketValidator { /// Allowed channels for validation final Map allowedChannels; - PacketValidator({required this.allowedChannels}); + /// When true, skip RSSI carpeater check (user setting) + final bool disableRssiFilter; + + PacketValidator({required this.allowedChannels, this.disableRssiFilter = false}); /// Validate packet for RX wardriving /// Returns ValidationResult with success/failure and reason - Future validate(PacketMetadata metadata) async { + /// [skipRssiCheck] - When true, skip the RSSI carpeater check (used for CARpeater pass-through) + Future validate(PacketMetadata metadata, {bool skipRssiCheck = false}) async { try { // Log packet for debugging final rawHex = metadata.raw @@ -34,15 +38,20 @@ class PacketValidator { debugLog('[RX FILTER] ========== VALIDATING PACKET =========='); debugLog('[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); debugLog('[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' - 'PathLength: ${metadata.pathLength} | SNR: ${metadata.snr}'); + 'PathHashCount: ${metadata.pathHashCount} | SNR: ${metadata.snr}'); // VALIDATION 1: Check RSSI (carpeater filter) - if (isCarpeater(metadata.rssi)) { + if (skipRssiCheck) { + debugLog('[RX FILTER] RSSI check skipped (CARpeater pass-through)'); + } else if (disableRssiFilter) { + debugLog('[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); + } else if (isCarpeater(metadata.rssi)) { debugLog('[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' 'possible carpeater (RSSI failsafe)'); return ValidationResult.failed('carpeater-rssi'); + } else { + debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); } - debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); // VALIDATION 2: Check packet type if (metadata.isGroupText) { @@ -163,6 +172,18 @@ class PacketValidator { return rssi >= maxRssiThreshold; } + /// Check if a hop hex string matches a stored CARpeater ID using prefix truncation. + /// The stored ID is always 3-byte (6 hex chars). Incoming hop IDs vary by the + /// packet's path hash size (1, 2, or 3 bytes). Compares the shorter prefix. + /// Also handles legacy shorter stored IDs from before the 3-byte requirement. + static bool isCarpeaterIdMatch(String hopHex, String storedId) { + final hop = hopHex.toUpperCase(); + final stored = storedId.toUpperCase(); + final compareLen = hop.length < stored.length ? hop.length : stored.length; + if (compareLen == 0) return false; + return hop.substring(0, compareLen) == stored.substring(0, compareLen); + } + /// Calculate ratio of printable ASCII characters (32-126) static double getPrintableRatio(String text) { if (text.isEmpty) return 0.0; diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 537d229..3dee89f 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -66,8 +66,10 @@ class CommandCodes { static const int sendControlData = 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) static const int setOtherParams = 38; static const int sendTelemetryReq = 39; + static const int setFloodScope = 54; // 0x36 - CMD_SET_FLOOD_SCOPE static const int getStats = 56; // 0x38 static const int sendBinaryReq = 50; + static const int setPathHashMode = 61; // 0x3D - CMD_SET_PATH_HASH_MODE } /// Response codes received from device diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 64b3950..0451fff 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -34,12 +34,17 @@ class RxLogger { /// Called with repeater ID and reason when a packet is dropped due to carpeater detection final void Function(String repeaterId, String reason)? onCarpeaterDrop; + /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped + /// to report the underlying repeater with null SNR/RSSI + String? carpeaterPrefix; + RxLogger({ required this.onRxEntry, this.onObservation, required this.getGpsLocation, this.shouldIgnoreRepeater, this.onCarpeaterDrop, + this.carpeaterPrefix, }); /// Start passive RX wardriving @@ -68,16 +73,35 @@ class RxLogger { // VALIDATION: Check path length (need at least one hop) // Packets with no path are direct transmissions and don't provide repeater coverage info - if (metadata.pathLength == 0) { + if (metadata.pathHashCount == 0) { debugLog('[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); return false; } + bool carpeaterStripped = false; + String repeaterId; + double? reportedSnr = metadata.snr; + int? reportedRssi = metadata.rssi; + // Extract LAST hop from path (the repeater that directly delivered to us) - // Do this early so we have repeater ID for carpeater logging - // Mask to last byte only (0xFF) for consistent 2-character display - final lastHopId = metadata.lastHop! & 0xFF; - final repeaterId = lastHopId.toRadixString(16).padLeft(2, '0').toUpperCase(); + final lastHopHex = metadata.lastHopHex!; + + // CARpeater check: the carpeater is co-located with us, so it only + // appears as the last hop (the delivery repeater) on RX packets + if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { + if (metadata.pathHashCount < 2) { + debugLog('[RX LOG] CARpeater pass-through: single-hop, dropping'); + return false; + } + // Second-to-last hop = the real repeater that forwarded to our carpeater + repeaterId = metadata.getHopHex(metadata.pathHashCount - 2)!; + carpeaterStripped = true; + reportedSnr = null; + reportedRssi = null; + debugLog('[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); + } else { + repeaterId = lastHopHex; + } // Get current GPS location final gpsLocation = getGpsLocation(); @@ -89,13 +113,15 @@ class RxLogger { // Check if this repeater should be ignored (user carpeater filter) // Must run before RSSI check so user never sees confusing "RSSI too strong" // errors for a device they told the app to ignore - if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { + // Skip for CARpeater pass-through (CARpeater itself was already handled) + if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { debugLog('[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // PACKET FILTER: Validate packet before logging - final validation = await validator.validate(metadata); + // Skip RSSI check for CARpeater pass-through + final validation = await validator.validate(metadata, skipRssiCheck: carpeaterStripped); if (!validation.valid) { final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) @@ -110,17 +136,17 @@ class RxLogger { return false; } - debugLog('[RX LOG] Packet heard via last hop: $repeaterId, ' - 'SNR=${metadata.snr}, path_length=${metadata.pathLength}'); + debugLog('[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' + 'SNR=$reportedSnr, path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); debugLog('[RX LOG] ✅ Packet validated and passed filter'); // Create observation for this packet final observation = RxObservation( repeaterId: repeaterId, - snr: metadata.snr, - rssi: metadata.rssi, - pathLength: metadata.pathLength, + snr: reportedSnr, + rssi: reportedRssi, + pathLength: metadata.pathHashCount, header: metadata.header, lat: gpsLocation.lat, lon: gpsLocation.lon, @@ -132,9 +158,9 @@ class RxLogger { // Returns true if this observation updated the batch (new repeater or better SNR) final wasKept = await _handleRxBatching( repeaterId: repeaterId, - snr: metadata.snr, - rssi: metadata.rssi, - pathLength: metadata.pathLength, + snr: reportedSnr, + rssi: reportedRssi, + pathLength: metadata.pathHashCount, header: metadata.header, currentLocation: gpsLocation, metadata: metadata, @@ -149,10 +175,10 @@ class RxLogger { final batchedObservation = _batchBuffer[repeaterId]?.bestObservation ?? observation; onObservation?.call(batchedObservation); debugLog('[RX LOG] ✅ Observation kept in batch: repeater=$repeaterId, ' - 'snr=${batchedObservation.snr}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); + 'snr=${batchedObservation.snr ?? 'null'}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); } else { debugLog('[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' - 'snr=${metadata.snr}, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); + 'snr=$reportedSnr, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); } return true; @@ -168,8 +194,8 @@ class RxLogger { /// Returns true if this observation was kept (new repeater or better SNR) Future _handleRxBatching({ required String repeaterId, - required double snr, - required int rssi, + required double? snr, + required int? rssi, required int pathLength, required int header, required ({double lat, double lon}) currentLocation, @@ -207,9 +233,14 @@ class RxLogger { debugLog('[RX BATCH] Started 30s timeout timer for repeater $repeaterId'); } else { // Already tracking this repeater - check if new SNR is better - if (snr > buffer.bestObservation.snr) { + // Null SNR never replaces non-null; non-null always replaces null + final existingSnr = buffer.bestObservation.snr; + final shouldUpdate = snr != null && existingSnr != null + ? snr > existingSnr + : snr != null && existingSnr == null; + if (shouldUpdate) { debugLog('[RX BATCH] Better SNR for repeater $repeaterId: ' - '${buffer.bestObservation.snr} -> $snr'); + '$existingSnr -> $snr'); // IMPORTANT: Keep the FIRST location where we heard this repeater, // only update the SNR/RSSI/metadata. This ensures the map pin stays // at the original location and doesn't follow the user. @@ -423,8 +454,8 @@ class RxBatch { /// Single RX observation class RxObservation { final String repeaterId; // Hex ID of the repeater - final double snr; - final int rssi; + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final double lat; @@ -434,8 +465,8 @@ class RxObservation { RxObservation({ required this.repeaterId, - required this.snr, - required this.rssi, + this.snr, + this.rssi, required this.pathLength, required this.header, required this.lat, @@ -450,8 +481,8 @@ class RxApiEntry { final String repeaterId; final double lat; final double lon; - final double snr; - final int rssi; + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final DateTime timestamp; @@ -461,8 +492,8 @@ class RxApiEntry { required this.repeaterId, required this.lat, required this.lon, - required this.snr, - required this.rssi, + this.snr, + this.rssi, required this.pathLength, required this.header, required this.timestamp, diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart new file mode 100644 index 0000000..ad7b529 --- /dev/null +++ b/lib/services/meshcore/trace_tracker.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../utils/debug_logger_io.dart'; + +/// Result of a trace path probe to a specific repeater +class TraceResult { + final String targetRepeaterId; + final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) + final int localRssi; // RSSI from BLE event metadata + final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) + final bool success; + + const TraceResult({ + required this.targetRepeaterId, + required this.localSnr, + required this.localRssi, + required this.remoteSnr, + required this.success, + }); +} + +/// Trace path tracker for targeted ping (zero-hop trace) +/// Sends CMD_SEND_TRACE_PATH (0x24) to a specific repeater and listens +/// for the trace response (PUSH_CODE_TRACE_DATA, 0x89). +/// +/// Follows the DiscTracker pattern but simpler: expects exactly 1 response. +class TraceTracker { + bool isListening = false; + Uint8List? _expectedTag; + String _targetRepeaterId = ''; + TraceResult? _result; + Timer? _windowTimer; + + /// BLE metadata from the 0x88 LogRxData event that arrives before the 0x89 TraceData. + /// Set by UnifiedRxHandler when it sees a trace packet in the LogRxData stream. + double pendingBleSnr = 0.0; + int pendingBleRssi = 0; + + /// Fired when a trace response is received during the window + void Function(TraceResult)? onTraceReceived; + + /// Fired when the listening window ends (result is null if no response) + void Function(TraceResult?)? onWindowComplete; + + TraceTracker(); + + /// Start tracking trace responses + void startTracking({ + required Uint8List tag, + required String targetRepeaterId, + Duration windowDuration = const Duration(seconds: 7), + }) { + debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); + debugLog('[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + + isListening = true; + _expectedTag = tag; + _targetRepeaterId = targetRepeaterId; + _result = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + // Start window timer + _windowTimer?.cancel(); + _windowTimer = Timer(windowDuration, _endWindow); + + debugLog('[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); + } + + /// Handle incoming trace data packet (0x89) + /// Returns true if the packet was a valid trace response + /// + /// Trace response format (per meshcore_py reference): + /// Byte 0: reserved (skip) + /// Byte 1: path_len (raw byte count of path hashes, NOT LogRxData encoding) + /// Byte 2: flags → hashSize = 1 << (flags & 3) + /// Bytes 3-6: tag → match against _expectedTag + /// Bytes 7-10: auth_code (skip) + /// Bytes 11 to 11+path_len: path_hashes → extract repeater ID + /// Next hopCount+1 bytes: path_snrs → each is signedInt8 / 4.0 for dB + /// For zero-hop (hopCount=1): remoteSnr = snrs[0], localSnr = snrs[1] + bool handlePacket(Uint8List rawBytes, double bleSnr, int bleRssi) { + if (!isListening) return false; + + try { + // Minimum: 1 (reserved) + 1 (path_len) + 1 (flags) + 4 (tag) + 4 (auth) = 11 bytes + if (rawBytes.length < 11) { + debugLog('[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); + return false; + } + + // Skip byte 0 (reserved) + final pathLen = rawBytes[1]; // Raw byte count of path hashes + final flags = rawBytes[2]; + + // Decode per 0x89 trace format (meshcore_py reference): + // hash size from flags, hop count from path_len / hash_size + final hashSize = 1 << (flags & 3); // 1, 2, 4, or 8 bytes per hop + final hopCount = hashSize > 0 ? pathLen ~/ hashSize : 0; + + debugLog('[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); + + // Extract tag (bytes 3-6) + final tag = rawBytes.sublist(3, 7); + + // Match tag against expected + final expectedTag = _expectedTag; + if (expectedTag != null) { + bool tagMatch = true; + for (int i = 0; i < 4; i++) { + if (tag[i] != expectedTag[i]) { + tagMatch = false; + break; + } + } + if (!tagMatch) { + debugLog('[TRACE] Tag mismatch, ignoring packet'); + return false; + } + } + + // Skip auth_code (bytes 7-10) + + // Extract path hashes (bytes 11 to 11 + hopCount*hashSize) + const pathStart = 11; + final pathEnd = pathStart + (hopCount * hashSize); + + if (rawBytes.length < pathEnd) { + debugLog('[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); + return false; + } + + // Extract repeater ID from first hop in path + String repeaterId = ''; + if (hopCount > 0) { + final idBytes = rawBytes.sublist(pathStart, pathStart + hashSize); + repeaterId = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + } + + // Extract path SNRs (hopCount+1 bytes after path hashes) + final snrStart = pathEnd; + final snrEnd = snrStart + hopCount + 1; + + double remoteSnr = 0.0; + double localSnr = bleSnr; // Default to BLE metadata SNR + + if (rawBytes.length >= snrEnd && hopCount >= 1) { + // Each SNR byte is signed int8, divide by 4.0 for dB + remoteSnr = rawBytes[snrStart].toSigned(8) / 4.0; + if (hopCount + 1 >= 2) { + localSnr = rawBytes[snrStart + 1].toSigned(8) / 4.0; + } + } + + debugLog('[TRACE] Trace response from $repeaterId: ' + 'localSnr=${localSnr.toStringAsFixed(2)}, ' + 'remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' + 'bleRssi=$bleRssi'); + + _result = TraceResult( + targetRepeaterId: _targetRepeaterId, + localSnr: localSnr, + localRssi: bleRssi, + remoteSnr: remoteSnr, + success: true, + ); + + // Notify callback + onTraceReceived?.call(_result!); + + return true; + } catch (e, stackTrace) { + debugError('[TRACE] Error processing trace response: $e'); + debugError('[TRACE] Stack trace: $stackTrace'); + return false; + } + } + + /// Stop tracking and return result + TraceResult? stopTracking() { + debugLog('[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); + + final result = _result; + isListening = false; + _windowTimer?.cancel(); + _windowTimer = null; + _expectedTag = null; + + return result; + } + + /// Handle trace window completion + void _endWindow() { + debugLog('[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); + + final result = _result; + isListening = false; + _windowTimer = null; + _expectedTag = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + onWindowComplete?.call(result); + } + + /// Dispose of resources + void dispose() { + stopTracking(); + } +} diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 999a16d..50b1d2c 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -7,7 +7,7 @@ import 'crypto_service.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; -/// TX echo tracker for repeater detection during 7-second window +/// TX echo tracker for repeater detection during 5-second window /// Reference: handleTxLogging() in wardrive.js (lines 3561-3710) class TxTracker { bool isListening = false; @@ -22,9 +22,14 @@ class TxTracker { Timer? _windowTimer; + /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped + /// to report the underlying repeater with null SNR/RSSI + String? carpeaterPrefix; + /// Callback fired when a new echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater - void Function(String repeaterId, double snr, int rssi, bool isNew)? onEchoReceived; + /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) + void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection @@ -34,6 +39,9 @@ class TxTracker { /// Returns true if the repeater should be filtered out bool Function(String repeaterId)? shouldIgnoreRepeater; + /// When true, skip RSSI carpeater check (user setting) + bool disableRssiFilter = false; + /// Start tracking echoes for a sent ping /// /// @param payload - The message text sent (for content verification) @@ -46,7 +54,7 @@ class TxTracker { required int channelIdx, required int channelHash, required Uint8List channelKey, - Duration windowDuration = const Duration(seconds: 7), + Duration windowDuration = const Duration(seconds: 5), }) { debugLog('[TX LOG] Starting echo tracking'); debugLog('[TX LOG] Payload: "$payload"'); @@ -78,7 +86,7 @@ class TxTracker { // Log final results if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { - debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr}, seen=${entry.value.seenCount}x'); + debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); } } } @@ -104,31 +112,55 @@ class TxTracker { // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) // Moved before RSSI check so we can log the repeater ID on carpeater drops - if (metadata.pathLength == 0) { + if (metadata.pathHashCount == 0) { debugLog('[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); return false; } // Extract first hop (first repeater) for use in validation and logging - final firstHopId = metadata.firstHop!; - final pathHex = firstHopId.toRadixString(16).padLeft(2, '0'); + var pathHex = metadata.firstHopHex!; + + // CARpeater pass-through: strip CARpeater hop and report underlying repeater + bool carpeaterStripped = false; + double? reportedSnr = metadata.snr; + int? reportedRssi = metadata.rssi; + + if (carpeaterPrefix != null && PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { + if (metadata.pathHashCount < 2) { + debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); + return false; + } + // Multi-hop: strip CARpeater, report underlying repeater (second hop) + final underlyingHex = metadata.getHopHex(1)!; + debugLog('[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); + pathHex = underlyingHex; + carpeaterStripped = true; + reportedSnr = null; + reportedRssi = null; + } // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) - if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { + if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { debugLog('[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); return false; } // VALIDATION STEP 2.5: Check RSSI (carpeater failsafe) - if (PacketValidator.isCarpeater(metadata.rssi)) { + // Skip for CARpeater pass-through (user explicitly identified their CARpeater) + if (carpeaterStripped) { + debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); + } else if (disableRssiFilter) { + debugLog('[TX LOG] RSSI filter disabled by user, skipping carpeater check'); + } else if (PacketValidator.isCarpeater(metadata.rssi)) { debugLog('[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater (RSSI failsafe), repeater=$pathHex'); debugLog('[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); onCarpeaterDrop?.call(pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); return false; // Mark as handled (dropped) + } else { + debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); } - debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); // VALIDATION STEP 3: Channel hash validation if (metadata.encryptedPayload.length < 3) { @@ -204,50 +236,53 @@ class TxTracker { // Path length and first hop already validated/extracted earlier (before RSSI check) - debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=${metadata.snr}, ' - 'full_path_length=${metadata.pathLength}'); + debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); // Deduplication: check if we already have this repeater bool isNewRepeater = false; if (repeaters.containsKey(pathHex)) { final existing = repeaters[pathHex]!; debugLog('[PING] Deduplication: path $pathHex already seen ' - '(existing SNR=${existing.snr}, new SNR=${metadata.snr})'); + '(existing SNR=${existing.snr}, new SNR=$reportedSnr)'); - // Keep the best (highest) SNR - if (metadata.snr > existing.snr) { + // Keep the best (highest) SNR — null SNR never replaces non-null + final shouldUpdate = reportedSnr != null && existing.snr != null + ? reportedSnr > existing.snr! + : reportedSnr != null && existing.snr == null; + if (shouldUpdate) { debugLog('[PING] Deduplication decision: updating path $pathHex with better SNR: ' - '${existing.snr} -> ${metadata.snr}'); + '${existing.snr} -> $reportedSnr'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, - snr: metadata.snr, - rssi: metadata.rssi, + snr: reportedSnr, + rssi: reportedRssi, seenCount: existing.seenCount + 1, ); } else { debugLog('[PING] Deduplication decision: keeping existing SNR for path $pathHex ' - '(existing ${existing.snr} >= new ${metadata.snr})'); + '(existing ${existing.snr} >= new $reportedSnr)'); // Still increment seen count existing.seenCount++; } } else { // New repeater isNewRepeater = true; - debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=${metadata.snr}, RSSI=${metadata.rssi}'); + debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); repeaters[pathHex] = RepeaterEcho( repeaterId: pathHex, - snr: metadata.snr, - rssi: metadata.rssi, + snr: reportedSnr, + rssi: reportedRssi, seenCount: 1, ); } // Notify callback for real-time UI updates final bestSnr = repeaters[pathHex]!.snr; - final rssi = repeaters[pathHex]!.rssi; + final bestRssi = repeaters[pathHex]!.rssi; debugLog('[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); if (onEchoReceived != null) { - onEchoReceived!(pathHex, bestSnr, rssi, isNewRepeater); + onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); debugLog('[TX LOG] onEchoReceived callback invoked successfully'); } @@ -269,14 +304,14 @@ class TxTracker { /// Repeater echo data class RepeaterEcho { final String repeaterId; // Hex string - double snr; // Best SNR seen - int rssi; // RSSI value (dBm) + double? snr; // Best SNR seen (null for CARpeater pass-through) + int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) int seenCount; // Times observed RepeaterEcho({ required this.repeaterId, - required this.snr, - required this.rssi, + this.snr, + this.rssi, this.seenCount = 1, }); } diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index f78c962..1c95dd2 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -4,6 +4,7 @@ import '../../utils/debug_logger_io.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; import 'rx_logger.dart'; +import 'trace_tracker.dart'; import 'tx_tracker.dart'; /// Unified RX handler - orchestrates TX echo tracking and passive RX logging @@ -18,6 +19,9 @@ class UnifiedRxHandler { /// Get current validator PacketValidator get validator => _validator; + /// Trace tracker for targeted ping mode (set by PingService when active) + TraceTracker? traceTracker; + /// Channel key for message decryption (injected for TX validation) Uint8List? channelKey; @@ -71,9 +75,22 @@ class UnifiedRxHandler { debugLog('[UNIFIED RX] Packet received: ' 'header=0x${metadata.header.toRadixString(16)}, ' - 'pathLength=${metadata.pathLength}'); - - // Route to TX tracking if active (during 7s echo window) + 'pathHashSize=${metadata.pathHashSize}, pathHashCount=${metadata.pathHashCount}'); + + // Store BLE metadata from 0x88 LogRxData for trace packets. + // The actual trace payload arrives separately via 0x89 TraceData stream. + // We only store RSSI/SNR here — the 0x89 handler combines them. + if (metadata.isTrace) { + final tt = traceTracker; + if (tt != null && tt.isListening) { + debugLog('[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); + tt.pendingBleSnr = metadata.snr; + tt.pendingBleRssi = metadata.rssi; + } + return; // Trace packets don't go to TX tracker or RX logger + } + + // Route to TX tracking if active (during 5s echo window) if (txTracker.isListening) { debugLog('[UNIFIED RX] TX tracking active - checking for echo'); final wasEcho = await txTracker.handlePacket(metadata); diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 282d911..d37cd8d 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -12,6 +12,7 @@ class OfflineSession { final Map data; final String? devicePublicKey; // Device public key for auth during upload final String? deviceName; // Device name for display + final String? contactUri; // Signed contact URI for registration during upload final bool uploaded; // Track upload status OfflineSession({ @@ -21,6 +22,7 @@ class OfflineSession { required this.data, this.devicePublicKey, this.deviceName, + this.contactUri, this.uploaded = false, }); @@ -33,6 +35,7 @@ class OfflineSession { data: json['data'] as Map, devicePublicKey: json['devicePublicKey'] as String?, deviceName: json['deviceName'] as String?, + contactUri: json['contactUri'] as String?, uploaded: json['uploaded'] as bool? ?? false, ); } @@ -46,6 +49,7 @@ class OfflineSession { 'data': data, 'devicePublicKey': devicePublicKey, 'deviceName': deviceName, + 'contactUri': contactUri, 'uploaded': uploaded, }; } @@ -59,6 +63,7 @@ class OfflineSession { data: data, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, uploaded: uploaded ?? this.uploaded, ); } @@ -78,6 +83,10 @@ class OfflineSessionService { SharedPreferences? _prefs; List _sessions = []; + /// Tracks the filename of the session currently being accumulated via periodic auto-save. + /// When set, `updateCurrentSession()` updates this session in-place instead of creating a new one. + String? _currentSessionFilename; + /// Callback when sessions list changes void Function(List sessions)? onSessionsUpdated; @@ -141,6 +150,7 @@ class OfflineSessionService { List> pings, { String? devicePublicKey, String? deviceName, + String? contactUri, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -167,6 +177,7 @@ class OfflineSessionService { data: sessionData, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, ); _sessions.insert(0, session); // Add at beginning (newest first) @@ -175,6 +186,70 @@ class OfflineSessionService { debugLog('[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); } + /// Update the current in-progress session with the latest pings snapshot. + /// If no current session exists, creates a new one and tracks it. + /// This allows periodic saves to update the same file instead of creating duplicates. + Future updateCurrentSession( + List> pings, { + String? devicePublicKey, + String? deviceName, + String? contactUri, + }) async { + if (pings.isEmpty) { + debugLog('[OFFLINE] No pings to auto-save, skipping'); + return; + } + + // If we have a tracked session, update it in-place + if (_currentSessionFilename != null) { + final index = _sessions.indexWhere((s) => s.filename == _currentSessionFilename); + if (index != -1) { + final existing = _sessions[index]; + final updatedData = Map.from(existing.data); + updatedData['pings'] = pings; + updatedData['ping_count'] = pings.length; + + _sessions[index] = OfflineSession( + filename: existing.filename, + createdAt: existing.createdAt, + pingCount: pings.length, + data: updatedData, + devicePublicKey: devicePublicKey ?? existing.devicePublicKey, + deviceName: deviceName ?? existing.deviceName, + contactUri: contactUri ?? existing.contactUri, + ); + await _saveSessions(); + debugLog('[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); + return; + } + // Session was deleted externally — fall through to create new + debugWarn('[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); + _currentSessionFilename = null; + } + + // No current session — create a new one and track it + await saveSession( + pings, + devicePublicKey: devicePublicKey, + deviceName: deviceName, + contactUri: contactUri, + ); + // saveSession inserts at index 0 (newest first) + if (_sessions.isNotEmpty) { + _currentSessionFilename = _sessions.first.filename; + debugLog('[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); + } + } + + /// Clear the current session tracker so the next save creates a fresh session file. + /// Called after final saves (mode switch, disconnect) to create a clean break. + void finalizeCurrentSession() { + if (_currentSessionFilename != null) { + debugLog('[OFFLINE] Finalized session: $_currentSessionFilename'); + _currentSessionFilename = null; + } + } + /// Mark a session as uploaded without deleting it Future markAsUploaded(String filename) async { final index = _sessions.indexWhere((s) => s.filename == filename); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 685cdbe..d782282 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:geolocator/geolocator.dart'; @@ -12,7 +13,9 @@ import 'countdown_timer_service.dart'; import 'gps_service.dart'; import 'meshcore/connection.dart'; import 'meshcore/disc_tracker.dart'; +import 'meshcore/trace_tracker.dart'; import 'meshcore/tx_tracker.dart'; +import 'meshcore/unified_rx_handler.dart'; import 'wakelock_service.dart'; /// Ping service for TX/RX ping orchestration @@ -22,7 +25,7 @@ import 'wakelock_service.dart'; /// 1. Validate GPS lock, 25m min distance (zone validation handled server-side) /// 2. Start TxTracker to monitor for repeater echoes /// 3. Send @[MapperBot] LAT, LON [POWERw] to #wardriving channel -/// 4. Start 6-second RX listening window (matches JS RX_LOG_LISTEN_WINDOW_MS) +/// 4. Start 5-second RX listening window /// 5. Post to API queue with type "TX" /// /// RX Flow (via TxTracker): @@ -33,21 +36,24 @@ import 'wakelock_service.dart'; /// /// Discovery Flow (Passive Mode only): /// 1. In Passive Mode, send discovery request instead of TX ping -/// 2. Start 7-second listening window via DiscTracker +/// 2. Start 5-second listening window via DiscTracker /// 3. Collect discovery responses (0x8E packets) /// 4. After window ends, create log entry and queue DISC API payloads class PingService { - /// RX listening window duration (7 seconds - matches cooldown duration) - static const Duration _rxListeningWindow = Duration(seconds: 7); - /// Cooldown period between pings (7 seconds - matches JS COOLDOWN_MS = 7000) - static const Duration _autoPingCooldown = Duration(seconds: 7); - /// Discovery listening window duration (7 seconds) - static const Duration _discoveryListeningWindow = Duration(seconds: 7); + /// RX listening window duration (5 seconds - matches cooldown duration) + static const Duration _rxListeningWindow = Duration(seconds: 5); + /// Cooldown period between pings (5 seconds) + static const Duration _autoPingCooldown = Duration(seconds: 5); + /// Discovery listening window duration (5 seconds) + static const Duration _discoveryListeningWindow = Duration(seconds: 5); /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); /// Cooldown period between manual pings (15 seconds) static const Duration _manualPingCooldown = Duration(seconds: 15); + /// Current configured min ping distance (for validation messages) + static int currentMinDistance = 25; + final GpsService _gpsService; final MeshCoreConnection _connection; final ApiQueueService _apiQueue; @@ -61,6 +67,24 @@ class PingService { final AudioService? _audioService; final bool Function(String repeaterId)? shouldIgnoreRepeater; + /// Number of bytes per hop in path hash (1, 2, or 3). Passed to DiscTracker for repeater ID length. + int _hopBytes; + + /// Number of bytes for trace path IDs (1, 2, or 4). Uses bitshift encoding, separate from TX. + int _traceHopBytes; + + /// Update hop bytes at runtime (e.g. when user changes path mode while connected) + set hopBytes(int value) => _hopBytes = value; + + /// Update trace hop bytes at runtime (e.g. when user changes trace byte setting) + set traceHopBytes(int value) => _traceHopBytes = value; + + /// When true, skip RSSI carpeater check in DiscTracker (user setting) + bool disableRssiFilter; + + /// Unified RX handler reference for routing trace packets + UnifiedRxHandler? unifiedRxHandler; + PingStats _stats = const PingStats(); DateTime? _lastTxTime; Timer? _rxWindowTimer; @@ -77,9 +101,17 @@ class PingService { bool _autoPingEnabled = false; bool _passiveModeEnabled = false; bool _hybridModeEnabled = false; + bool _targetedModeEnabled = false; bool _nextPingIsDiscovery = true; // Start hybrid with discovery Timer? _autoTimer; + // Targeted mode tracking + TraceTracker? _traceTracker; + StreamSubscription? _traceDataSubscription; + Timer? _targetedTimer; + String? _targetRepeaterId; + Position? _lastTargetedPosition; + // Pending disable flag - when true, disable will execute after RX window ends bool _pendingDisable = false; @@ -105,6 +137,9 @@ class PingService { /// Callback to get the external antenna value for API payloads bool Function()? getExternalAntenna; + /// Callback to check if discovery drop is enabled (failed discoveries → API) + bool Function()? getDiscDropEnabled; + /// Callback to check if TX is allowed by API (zone capacity check) bool Function()? checkTxAllowed; @@ -132,6 +167,13 @@ class PingService { /// Parameters: (bool success) - true if any nodes discovered, false if none void Function(bool success)? onDiscoveryWindowComplete; + /// Callback when trace window ends (for noise floor graph + log) + /// Parameters: (TraceResult? result) - null if no response + void Function(TraceResult? result)? onTraceWindowComplete; + + /// Callback when trace ping is sent (for log entry creation) + void Function(TraceLogEntry)? onTracePing; + /// Callback when pingInProgress changes (for immediate UI refresh) void Function()? onPingProgressChanged; @@ -159,6 +201,9 @@ class PingService { TxTracker? txTracker, AudioService? audioService, this.shouldIgnoreRepeater, + this.disableRssiFilter = false, + int hopBytes = 1, + int traceHopBytes = 1, }) : _gpsService = gpsService, _connection = connection, _apiQueue = apiQueue, @@ -169,7 +214,9 @@ class PingService { _discoveryWindowCountdown = discoveryWindowTimer, _deviceId = deviceId, _txTracker = txTracker, - _audioService = audioService; + _audioService = audioService, + _hopBytes = hopBytes, + _traceHopBytes = traceHopBytes; /// Get current ping statistics PingStats get stats => _stats; @@ -186,6 +233,9 @@ class PingService { /// Check if Hybrid Mode is active (alternates discovery + TX) bool get isHybridMode => _hybridModeEnabled; + /// Check if Targeted Mode is active (zero-hop trace to specific repeater) + bool get isTargetedMode => _targetedModeEnabled; + /// Check if discovery tracker is currently listening (for Passive Mode UI) bool get isDiscoveryListening => _discTracker?.isListening ?? false; @@ -265,7 +315,7 @@ class PingService { return PingValidation.tooCloseToLastPing; } - // Check cooldown (7 seconds between pings) + // Check cooldown (5 seconds between pings) final lastTx = _lastTxTime; if (lastTx != null) { final elapsed = DateTime.now().difference(lastTx); @@ -374,7 +424,7 @@ class PingService { // NOTE: Skip distance check (tooCloseToLastPing) intentionally // Auto mode handles this by setting skipReason='too close' and scheduling next ping - // Check cooldown (7 seconds between pings) + // Check cooldown (5 seconds between pings) final lastTx = _lastTxTime; if (lastTx != null) { final elapsed = DateTime.now().difference(lastTx); @@ -447,7 +497,7 @@ class PingService { return false; } } else { - // Auto ping: 7-second cooldown, 25m distance check + // Auto ping: 5-second cooldown, 25m distance check // This fixes a race condition where disabling Active Mode during cooldown // could still trigger an auto-ping from a late RX window timer callback if (isInCooldown()) { @@ -582,7 +632,7 @@ class PingService { // Manual ping: 15-second cooldown, no distance check _manualPingCooldownTimer.start(_manualPingCooldown.inMilliseconds); } else { - // Auto ping: 7-second cooldown + // Auto ping: 5-second cooldown _cooldownTimer.start(_autoPingCooldown.inMilliseconds); } @@ -610,14 +660,13 @@ class PingService { } } - /// Start the 6-second RX listening window after TX + /// Start the 5-second RX listening window after TX /// Note: TxTracker handles the actual echo tracking, we just manage the countdown UI - /// Reference: RX_LOG_LISTEN_WINDOW_MS = 6000 in wardrive.js void _startRxListeningWindow(Position txPosition) { // Cancel previous timer _rxWindowTimer?.cancel(); - // Start RX window countdown display (6 seconds - matches JS RX_LOG_LISTEN_WINDOW_MS) + // Start RX window countdown display (5 seconds) _rxWindowCountdown.start(_rxListeningWindow.inMilliseconds); // Set timer for window end @@ -647,8 +696,10 @@ class PingService { for (final entry in txTracker.repeaters.entries) { final repeaterId = entry.key; final echo = entry.value; - // Format SNR with 2 decimal places - repeaterStrings.add('$repeaterId(${echo.snr.toStringAsFixed(2)})'); + // Format SNR with 2 decimal places, or "null" for CARpeater pass-through + repeaterStrings.add(echo.snr != null + ? '$repeaterId(${echo.snr!.toStringAsFixed(2)})' + : '$repeaterId(null)'); debugLog('[PING] Heard repeater: $repeaterId, SNR=${echo.snr}'); } heardRepeats = repeaterStrings.join(','); @@ -691,9 +742,11 @@ class PingService { debugLog('[PING] Executing pending disable after RX window'); _pendingDisable = false; final wasHybrid = _hybridModeEnabled; + final wasTargeted = _targetedModeEnabled; _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; _autoTimer?.cancel(); _autoTimer = null; @@ -701,6 +754,10 @@ class PingService { if (wasHybrid) { _stopDiscoveryMode(); } + // Clean up targeted infrastructure if targeted was enabled + if (wasTargeted) { + _stopTargetedMode(); + } // Start cooldown immediately _cooldownTimer.start(_autoPingCooldown.inMilliseconds); debugLog('[PING] Pending disable complete, cooldown started'); @@ -793,20 +850,34 @@ class PingService { } } - /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), or Hybrid Mode + /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), + /// Hybrid Mode, or Targeted Mode (zero-hop trace) /// Reference: startAutoPing() in wardrive.js /// @param passiveMode - If true, only listens for RX (no TX pings) - this is Passive Mode /// @param hybridMode - If true, alternates discovery + TX pings each interval - Future enableAutoPing({bool passiveMode = false, bool hybridMode = false}) async { - debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode)'); + /// @param targetedMode - If true, sends trace path to specific repeater + /// @param targetRepeaterId - Repeater ID hex string (required when targetedMode=true) + Future enableAutoPing({ + bool passiveMode = false, + bool hybridMode = false, + bool targetedMode = false, + String? targetRepeaterId, + }) async { + debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); if (_autoPingEnabled) { debugLog('[AUTO] Auto mode already enabled'); return false; } + // Targeted mode requires a repeater ID + if (targetedMode && (targetRepeaterId == null || targetRepeaterId.isEmpty)) { + debugLog('[AUTO] Targeted mode requires a repeater ID'); + return false; + } + // Check if we're in cooldown (can't start during cooldown) - // Hybrid and Active modes are blocked by cooldown, Passive is not + // Hybrid, Active, and Targeted modes are blocked by cooldown, Passive is not // Reference: isInCooldown() check in startAutoPing() in wardrive.js if (!passiveMode && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); @@ -824,14 +895,23 @@ class PingService { _autoPingEnabled = true; _passiveModeEnabled = passiveMode; _hybridModeEnabled = hybridMode; + _targetedModeEnabled = targetedMode; _nextPingIsDiscovery = true; // Always start hybrid with discovery + if (targetedMode) { + _targetRepeaterId = targetRepeaterId; + } + // Enable wake lock to keep screen on during auto mode // Reference: acquireWakeLock() in wardrive.js debugLog('[AUTO] Acquiring wake lock for auto mode'); await _wakelockService.enable(); - if (hybridMode) { + if (targetedMode) { + // Targeted Mode: send trace path to specific repeater + debugLog('[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); + await _startTargetedMode(); + } else if (hybridMode) { // Hybrid Mode: set up discovery infrastructure, then start with discovery debugLog('[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); await _startDiscoveryMode(); @@ -884,14 +964,20 @@ class PingService { // Clear skip reason _skipReason = null; - // Clean up discovery infrastructure if hybrid was enabled - if (_hybridModeEnabled) { + // Clean up discovery infrastructure if passive or hybrid was enabled + if (_passiveModeEnabled || _hybridModeEnabled) { _stopDiscoveryMode(); } + // Clean up targeted mode infrastructure + if (_targetedModeEnabled) { + _stopTargetedMode(); + } + _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; // Disable wake lock when auto mode stops @@ -912,8 +998,10 @@ class PingService { _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; _stopDiscoveryMode(); + _stopTargetedMode(); await _wakelockService.disable(); } @@ -932,7 +1020,11 @@ class PingService { debugLog('[DISC] Starting discovery mode'); // Create and configure discovery tracker - final tracker = DiscTracker(shouldIgnoreRepeater: shouldIgnoreRepeater); + final tracker = DiscTracker( + shouldIgnoreRepeater: shouldIgnoreRepeater, + disableRssiFilter: disableRssiFilter, + hopBytes: _hopBytes, + ); _discTracker = tracker; tracker.onCarpeaterDrop = onDiscCarpeaterDrop; tracker.onNodeDiscovered = (node, isNew) { @@ -1012,8 +1104,8 @@ class PingService { position.latitude, position.longitude, ); - if (distance < GpsService.minDistanceMeters) { - debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < 25m), skipping'); + if (distance < _gpsService.configuredMinDistance) { + debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextDiscovery(); @@ -1064,7 +1156,7 @@ class PingService { windowDuration: _discoveryListeningWindow, ); - // Start discovery window countdown display (7 seconds) + // Start discovery window countdown display (5 seconds) _discoveryWindowCountdown.start(_discoveryListeningWindow.inMilliseconds); // Clear pingInProgress now that discovery window is active @@ -1121,6 +1213,18 @@ class PingService { onStatsUpdated?.call(_stats); } else { debugLog('[DISC] No nodes discovered'); + + // Queue failed discovery to API if disc drop is enabled + if (getDiscDropEnabled?.call() == true) { + _apiQueue.enqueueDiscDrop( + latitude: position.latitude, + longitude: position.longitude, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + ); + debugLog('[DISC] Discovery drop queued (no response)'); + } } // Entry already added to log via onDiscPing - no need to fire onDiscoveryComplete @@ -1171,8 +1275,8 @@ class PingService { _autoTimer = null; // Subtract listening window so interval is measured start-to-start - // At 15s: wait = 15000 - 7000 = 8000ms. Clamp to min 1s. - final listenMs = _rxListeningWindow.inMilliseconds; // 7000 + // At 15s: wait = 15000 - 5000 = 10000ms. Clamp to min 1s. + final listenMs = _rxListeningWindow.inMilliseconds; // 5000 final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; @@ -1199,6 +1303,219 @@ class PingService { }); } + // ============================================ + // Targeted Mode (Zero-Hop Trace) + // ============================================ + + /// Start targeted mode - subscribes to trace data and sends first trace + Future _startTargetedMode() async { + debugLog('[TRACE] Starting targeted mode for repeater $_targetRepeaterId'); + + // Create trace tracker + final tracker = TraceTracker(); + _traceTracker = tracker; + tracker.onTraceReceived = (result) { + debugLog('[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + }; + tracker.onWindowComplete = (result) { + debugLog('[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); + _handleTraceWindowComplete(result); + }; + + // Wire trace tracker into UnifiedRxHandler so 0x88 BLE metadata + // gets stored for the 0x89 handler + unifiedRxHandler?.traceTracker = tracker; + + // Subscribe to 0x89 TraceData stream for actual trace payloads + _traceDataSubscription = _connection.traceDataStream.listen((raw) { + final tt = _traceTracker; + if (tt != null && tt.isListening) { + tt.handlePacket(raw, tt.pendingBleSnr, tt.pendingBleRssi); + } + }); + + // Send first trace immediately + await _sendTargetedPing(); + } + + /// Stop targeted mode - cleans up tracker and subscription + void _stopTargetedMode() { + debugLog('[TRACE] Stopping targeted mode'); + _targetedTimer?.cancel(); + _targetedTimer = null; + _traceDataSubscription?.cancel(); + _traceDataSubscription = null; + unifiedRxHandler?.traceTracker = null; + _traceTracker?.dispose(); + _traceTracker = null; + _lastTargetedPosition = null; + } + + /// Send a targeted ping (trace path) and start listening window + Future _sendTargetedPing() async { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, skipping trace'); + return; + } + + final targetId = _targetRepeaterId; + if (targetId == null || targetId.isEmpty) { + debugLog('[TRACE] No target repeater ID, skipping trace'); + _scheduleNextTargetedPing(); + return; + } + + // Check GPS + final position = _gpsService.lastPosition; + if (position == null) { + debugLog('[TRACE] No GPS position, skipping trace'); + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + + // Check minimum distance from last trace (25m) + final lastPos = _lastTargetedPosition; + if (lastPos != null) { + final distance = Geolocator.distanceBetween( + lastPos.latitude, lastPos.longitude, + position.latitude, position.longitude, + ); + if (distance < _gpsService.configuredMinDistance) { + debugLog('[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + _skipReason = 'too close'; + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + } + + // Clear skip reason since we're proceeding + _skipReason = null; + + // Signal "Sending..." to UI + _pingInProgress = true; + onPingProgressChanged?.call(); + + // Capture noise floor + final noiseFloor = _connection.lastNoiseFloor; + _pendingTxNoiseFloor = noiseFloor; + + // Create trace log entry immediately + final traceEntry = TraceLogEntry( + timestamp: DateTime.now(), + latitude: position.latitude, + longitude: position.longitude, + targetRepeaterId: targetId, + noiseFloor: noiseFloor, + success: false, // Will be updated after window completes + ); + onTracePing?.call(traceEntry); + + debugLog('[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + + try { + // Play transmit sound + _audioService?.playTransmitSound(); + + // Convert hex repeater ID to bytes (trace uses separate byte size: 1, 2, or 4) + final traceBytes = _traceHopBytes; + final repeaterIdBytes = Uint8List(traceBytes); + for (int i = 0; i < traceBytes && i * 2 + 2 <= targetId.length; i++) { + repeaterIdBytes[i] = int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); + } + + // Send trace path and get tag + final tag = await _connection.sendTracePath(repeaterIdBytes, hopBytes: traceBytes); + + // Start tracking with the tag + _traceTracker?.startTracking( + tag: tag, + targetRepeaterId: targetId, + windowDuration: _rxListeningWindow, + ); + + // Start listening window countdown display + _discoveryWindowCountdown.start(_rxListeningWindow.inMilliseconds); + + // Clear pingInProgress now that trace window is active + _pingInProgress = false; + + // Update last targeted position for 25m check + _lastTargetedPosition = position; + + } catch (e) { + _pingInProgress = false; + debugError('[TRACE] Failed to send trace: $e'); + _scheduleNextTargetedPing(); + } + } + + /// Handle trace window completion + void _handleTraceWindowComplete(TraceResult? result) { + _discoveryWindowCountdown.stop(); + final position = _lastTargetedPosition; + final targetId = _targetRepeaterId ?? ''; + + if (result != null && result.success && position != null) { + debugLog('[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + + // Queue to API (only successful traces) + _apiQueue.enqueueTrace( + latitude: position.latitude, + longitude: position.longitude, + repeaterId: targetId, + localSnr: result.localSnr, + localRssi: result.localRssi, + remoteSnr: result.remoteSnr, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + ); + + // Update stats + _stats = _stats.copyWith(traceCount: _stats.traceCount + 1); + onStatsUpdated?.call(_stats); + + // Play receive sound for successful trace + _audioService?.playReceiveSound(); + } else { + debugLog('[TRACE] Trace failed: no response from $targetId'); + // Failed traces are NOT posted to API (local visual only) + } + + // Notify for noise floor graph and log updates + onTraceWindowComplete?.call(result); + + _scheduleNextTargetedPing(); + } + + /// Schedule next targeted ping after interval + void _scheduleNextTargetedPing() { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, not scheduling next trace'); + return; + } + + _targetedTimer?.cancel(); + _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { + debugLog('[TRACE] Targeted ping timer fired'); + if (_autoPingEnabled && _targetedModeEnabled) { + if (_pingInProgress) { + debugLog('[TRACE] Ping already in progress, skipping'); + return; + } + _skipReason = null; + _sendTargetedPing(); + } + }); + + // Notify callback for countdown display + onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); + + debugLog('[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); + } + /// Stop any active TX echo tracking window /// Called when disabling auto mode to prevent late timer callbacks from /// triggering pings during cooldown (race condition fix) @@ -1221,6 +1538,7 @@ class PingService { _autoTimer?.cancel(); _autoTimer = null; _stopDiscoveryMode(); + _stopTargetedMode(); _wakelockService.dispose(); } } @@ -1255,7 +1573,7 @@ enum PingValidation { /// Too close to last ping (< 25m) tooCloseToLastPing, - /// Cooldown period active (< 7s since last ping) + /// Cooldown period active (< 5s since last ping) cooldownActive, /// Manual ping cooldown period active (< 15s since last manual ping) @@ -1285,9 +1603,9 @@ extension PingValidationExtension on PingValidation { case PingValidation.outsideGeofence: return 'Outside service area'; case PingValidation.tooCloseToLastPing: - return 'Move 25m before next ping'; + return 'Move ${PingService.currentMinDistance}m before next ping'; case PingValidation.cooldownActive: - return 'Wait 7 seconds between pings'; + return 'Wait 5 seconds between pings'; case PingValidation.manualCooldownActive: return 'Wait 15 seconds between manual pings'; case PingValidation.txNotAllowed: diff --git a/lib/utils/debug_logger_stub.dart b/lib/utils/debug_logger_stub.dart index b7e96ba..4dc5a98 100644 --- a/lib/utils/debug_logger_stub.dart +++ b/lib/utils/debug_logger_stub.dart @@ -20,8 +20,8 @@ class DebugLogger { if (_initialized) return; _initialized = true; - // On mobile, enable debug logging in debug mode - _debugEnabled = kDebugMode; + // Enable debug logging by default on all builds + _debugEnabled = true; if (_debugEnabled) { debugPrint('[DEBUG] Debug logging ENABLED (debug mode)'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 82dab78..7d2e2ef 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -80,6 +80,18 @@ extension MapStyleExtension on MapStyle { return null; // ArcGIS doesn't use subdomains } } + + /// Whether this style supports retina tiles via {r} placeholder + bool get supportsRetina { + switch (this) { + case MapStyle.dark: + return true; // Carto supports @2x via {r} + case MapStyle.light: + return false; // OSM has no retina support + case MapStyle.satellite: + return false; // ArcGIS has no retina support + } + } } /// Custom tile provider that silently handles HTTP errors (404, 503, etc.) @@ -557,11 +569,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Map _buildMap(appState, center), - // GPS Info overlay (top-left, respects dynamic island in landscape) + // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) Positioned( top: topPadding, left: leftPadding, - child: _buildGpsInfoOverlay(appState), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildGpsInfoOverlay(appState), + if (appState.preferences.showTopRepeaters) ...[ + const SizedBox(height: 6), + _buildTopRepeatersOverlay(appState), + ], + ], + ), ), // Map controls - top-right in both orientations, collapsible @@ -616,7 +637,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { initialCenter: center, initialZoom: _defaultZoom, minZoom: 3, - maxZoom: 18, + maxZoom: 17, interactionOptions: InteractionOptions( flags: _rotationLocked ? InteractiveFlag.all & ~InteractiveFlag.rotate @@ -639,9 +660,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { urlTemplate: mapStyle.urlTemplate, subdomains: mapStyle.subdomains ?? const [], userAgentPackageName: 'com.meshmapper.app', - maxZoom: 19, - retinaMode: RetinaMode.isHighDensity(context), // Enable high-res tiles on retina displays - tileProvider: SilentCancellableNetworkTileProvider(), // Silently handles tile errors + maxZoom: 17, + retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), + tileProvider: SilentCancellableNetworkTileProvider(), ); }, ), @@ -649,9 +670,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // MeshMapper coverage overlay (only when zone code available and overlay enabled) if (appState.zoneCode != null && _showMeshMapperOverlay) TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}', + urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', userAgentPackageName: 'com.meshmapper.app', - maxZoom: 19, + minZoom: 3, + maxZoom: 17, + tileDisplay: const TileDisplay.fadeIn( + reloadStartOpacity: 1.0, // Keep old tile visible until new one loads + ), tileProvider: SilentCancellableNetworkTileProvider(), ), @@ -667,12 +692,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // DISC markers (purple circles for discovery observations) MarkerLayer( - markers: _buildDiscMarkers(appState.discLogEntries), + markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), + ), + + // Trace markers (cyan/red circles for targeted ping results) + MarkerLayer( + markers: _buildTraceMarkers(appState.traceLogEntries), ), - // Repeater markers (magenta circles with ID) + // Repeater markers (magenta with ID, rotate with map) MarkerLayer( - markers: _buildRepeaterMarkers(appState.repeaters), + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + ), ), // Current position marker (car icon) @@ -695,7 +729,128 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } + /// Color for the overlay ping-type dot + static Color _overlayTypeColor(OverlayPingType type) { + return switch (type) { + OverlayPingType.tx => Colors.green, + OverlayPingType.disc => Colors.purple, + OverlayPingType.trace => Colors.cyan, + OverlayPingType.rx => Colors.blue, + }; + } + + /// Build a single overlay table row with colored dot, repeater ID, and SNR + TableRow _overlayRow(String repeaterId, double snr, Color dotColor) { + return TableRow( + children: [ + TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dotColor, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + repeaterId, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Colors.white, + ), + ), + ), + const SizedBox(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + snr.toStringAsFixed(1), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: _snrColor(snr), + ), + ), + ), + ], + ); + } + /// GPS info overlay (top-left corner) + /// Top heard repeaters overlay (bottom-right of map) + Widget _buildTopRepeatersOverlay(AppStateProvider appState) { + final topRepeaters = appState.topRepeatersBySnr; + final rxSlot = appState.rxOverlaySlot; + final isEmpty = topRepeaters.isEmpty && rxSlot == null; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Top Heard', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: Colors.white54, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 2), + if (isEmpty) + const Text( + '---', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.white38, + ), + ), + if (!isEmpty) + Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + columnWidths: const { + 0: IntrinsicColumnWidth(), // dot + 1: IntrinsicColumnWidth(), // ID + 2: FixedColumnWidth(8), // spacer + 3: IntrinsicColumnWidth(), // SNR + }, + children: [ + for (final r in topRepeaters) + _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), + if (rxSlot != null) + _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), + ], + ), + ], + ), + ); + } + + /// SNR color: green > 5, orange -1..5, red <= -1 + static Color _snrColor(double snr) { + if (snr <= -1) return Colors.red; + if (snr <= 5) return Colors.orange; + return Colors.green; + } + Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; final hasGps = position != null; @@ -1100,12 +1255,26 @@ class _MapWidgetState extends State with TickerProviderStateMixin { description: 'Location where you sent a discovery request and a repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: Colors.cyan, + label: 'TRC', + description: 'Location where a trace reached the repeater', + ), + Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, color: Colors.grey, label: 'DISC', description: 'Location where you sent a discovery request but no repeater responded', ), + Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: Colors.grey, + label: 'TRC', + description: 'Location where a trace got no response', + ), ], ), ), @@ -1550,7 +1719,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }).toList(); } - List _buildDiscMarkers(List entries) { + List _buildDiscMarkers(List entries, bool discDropEnabled) { return entries.map((entry) { return Marker( point: LatLng(entry.latitude, entry.longitude), @@ -1560,7 +1729,36 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onTap: () => _showDiscPingDetails(entry), child: Container( decoration: BoxDecoration( - color: entry.nodeCount == 0 ? Colors.grey : _discMarkerColor, + color: entry.nodeCount == 0 + ? (discDropEnabled ? Colors.red : Colors.grey) + : _discMarkerColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + ), + ), + ); + }).toList(); + } + + List _buildTraceMarkers(List entries) { + return entries.map((entry) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: Container( + decoration: BoxDecoration( + color: entry.success ? Colors.cyan : Colors.grey, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ @@ -1577,6 +1775,257 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }).toList(); } + void _showTraceDetails(TraceLogEntry entry) { + showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.cyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trace', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Target repeater section header + Text( + entry.success + ? 'Target Repeater' + : 'No response from target repeater', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + + if (entry.success) ...[ + const SizedBox(height: 12), + // Table with headers + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: _nodeColumnWidth(), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data row + Builder(builder: (context) { + final localSnr = entry.localSnr ?? 0; + final localRssi = entry.localRssi ?? 0; + final remoteSnr = entry.remoteSnr ?? 0; + + Color rxSnrColor; + if (localSnr <= -1) { + rxSnrColor = Colors.red; + } else if (localSnr <= 5) { + rxSnrColor = Colors.orange; + } else { + rxSnrColor = Colors.green; + } + + Color rssiColor; + if (localRssi >= -70) { + rssiColor = Colors.green; + } else if (localRssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + Color txSnrColor; + if (remoteSnr <= -1) { + txSnrColor = Colors.red; + } else if (remoteSnr <= 5) { + txSnrColor = Colors.orange; + } else { + txSnrColor = Colors.green; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), + ), + ), + // RX RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '$localRssi', + color: rssiColor, + ), + ), + ), + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + /// DISC marker color (#7B68EE - medium slate blue/purple) static const Color _discMarkerColor = Color(0xFF7B68EE); @@ -1616,23 +2065,39 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers(List repeaters) { + List _buildRepeaterMarkers(List repeaters, int? regionHopBytesOverride) { final duplicateIds = _getDuplicateRepeaterIds(repeaters); return repeaters.map((repeater) { final isDuplicate = duplicateIds.contains(repeater.id); final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); + // Display hex ID based on per-repeater hop_bytes (or regional admin override) + final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; + final isLongId = displayId.length > 2; + final markerWidth = displayId.length > 4 ? 48.0 : isLongId ? 40.0 : 28.0; + + // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded + final borderRadius = effectiveBytes >= 3 + ? BorderRadius.circular(8) + : effectiveBytes == 2 + ? BorderRadius.circular(6) + : BorderRadius.circular(4); + return Marker( point: LatLng(repeater.lat, repeater.lon), - width: 28, + width: markerWidth, height: 28, child: GestureDetector( - onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate), + onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate, regionHopBytesOverride: regionHopBytesOverride), child: Container( + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 4) + : EdgeInsets.zero, decoration: BoxDecoration( color: markerColor, - shape: BoxShape.circle, + borderRadius: borderRadius, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ BoxShadow( @@ -1644,11 +2109,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), alignment: Alignment.center, child: Text( - repeater.id, - style: const TextStyle( - fontSize: 10, + displayId, + style: TextStyle( + fontSize: displayId.length > 4 ? 8 : isLongId ? 9 : 10, fontWeight: FontWeight.bold, color: Colors.white, + fontFamily: 'monospace', ), ), ), @@ -1672,6 +2138,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } + /// Compute node column width based on hop byte count. + /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). + double _nodeColumnWidth({double extraPadding = 0}) { + final appState = context.read(); + final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; + switch (hopBytes) { + case 2: + return 70 + extraPadding; + case 3: + return 80 + extraPadding; + default: + return 60 + extraPadding; + } + } + /// Show TX ping details popup void _showTxPingDetails(TxPing ping) { // Use the heardRepeaters directly from the TxPing @@ -1680,209 +2161,221 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, + isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge - Row( + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Icon badge + // Header with icon badge + Row( + children: [ + // Icon badge + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TX Ping', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(ping.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - _formatTime(ping.timestamp), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${ping.latitude.toStringAsFixed(5)}, ${ping.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), ], ), ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 20), - - // Location chip - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Row( - children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${ping.latitude.toStringAsFixed(5)}, ${ping.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + const SizedBox(height: 16), + + // Repeaters section header + Text( + heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, ), - ], - ), - ), - const SizedBox(height: 16), - - // Repeaters section header - Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), - ), - - if (heardRepeaters.isNotEmpty) ...[ - const SizedBox(height: 12), - // Repeaters table - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), + + if (heardRepeaters.isNotEmpty) ...[ + const SizedBox(height: 12), + // Repeaters table + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...heardRepeaters.map((repeater) { - // Calculate SNR chip color - Color snrColor; - if (repeater.snr <= -1) { - snrColor = Colors.red; - } else if (repeater.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color - Color rssiColor; - if (repeater.rssi >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: 60), - // SNR + SizedBox( + width: _nodeColumnWidth(), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), Expanded( - child: Center( - child: _buildStatChip( - value: repeater.snr.toStringAsFixed(1), - color: snrColor, + child: Text( + 'SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - // RSSI Expanded( - child: Center( - child: _buildStatChip( - value: '${repeater.rssi}', - color: rssiColor, + child: Text( + 'RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ], ), ), - ); - }), - ], - ), - ), - ], - ], + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...heardRepeaters.map((repeater) { + // Calculate SNR chip color + Color snrColor; + if (repeater.snr == null) { + snrColor = Colors.grey; + } else if (repeater.snr! <= -1) { + snrColor = Colors.red; + } else if (repeater.snr! <= 5) { + snrColor = Colors.orange; + } else { + snrColor = Colors.green; + } + + // Calculate RSSI chip color + Color rssiColor; + if (repeater.rssi == null) { + rssiColor = Colors.grey; + } else if (repeater.rssi! >= -70) { + rssiColor = Colors.green; + } else if (repeater.rssi! >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + // Repeater ID + RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + // SNR + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.snr?.toStringAsFixed(1) ?? '-', + color: snrColor, + ), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.rssi != null ? '${repeater.rssi}' : '-', + color: rssiColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), ), ), ); @@ -2021,7 +2514,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: 60, + width: _nodeColumnWidth(), child: Text( 'Node', style: TextStyle( @@ -2065,7 +2558,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: 60), + RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -2102,254 +2595,262 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, + isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge - Row( + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Icon badge + // Header with icon badge + Row( + children: [ + // Icon badge + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _discMarkerColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Disc Request', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: _discMarkerColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - _formatTime(entry.timestamp), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), ), ), ], ), ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 20), - - // Location chip - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Row( - children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + const SizedBox(height: 16), + + // Discovered nodes section header + Text( + entry.discoveredNodes.isEmpty + ? 'No nodes discovered' + : 'Discovered Nodes (${entry.discoveredNodes.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, ), - ], - ), - ), - const SizedBox(height: 16), - - // Discovered nodes section header - Text( - entry.discoveredNodes.isEmpty - ? 'No nodes discovered' - : 'Discovered Nodes (${entry.discoveredNodes.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), - ), - - if (entry.discoveredNodes.isNotEmpty) ...[ - const SizedBox(height: 12), - // Table with headers - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), + + if (entry.discoveredNodes.isNotEmpty) ...[ + const SizedBox(height: 12), + // Table with headers + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) { - // Calculate colors - Color rxSnrColor; - if (node.localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (node.localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Node ID with type SizedBox( - width: 60, - child: Row( - children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), - Text( - node.nodeTypeLabel, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: _discMarkerColor, - ), - ), - ], + width: _nodeColumnWidth(extraPadding: 20), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), - // RX SNR Expanded( - child: Center( - child: _buildStatChip( - value: node.localSnr.toStringAsFixed(1), - color: rxSnrColor, + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - // RSSI Expanded( - child: Center( - child: _buildStatChip( - value: '${node.localRssi}', - color: rssiColor, + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - // TX SNR Expanded( - child: Center( - child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), - color: txSnrColor, + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ], ), ), - ); - }), - ], - ), - ), - ], - ], + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...entry.discoveredNodes.map((node) { + // Calculate colors + Color rxSnrColor; + if (node.localSnr <= -1) { + rxSnrColor = Colors.red; + } else if (node.localSnr <= 5) { + rxSnrColor = Colors.orange; + } else { + rxSnrColor = Colors.green; + } + + Color rssiColor; + if (node.localRssi >= -70) { + rssiColor = Colors.green; + } else if (node.localRssi >= -100) { + rssiColor = Colors.orange; + } else { + rssiColor = Colors.red; + } + + Color txSnrColor; + if (node.remoteSnr <= -1) { + txSnrColor = Colors.red; + } else if (node.remoteSnr <= 5) { + txSnrColor = Colors.orange; + } else { + txSnrColor = Colors.green; + } + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + // Node ID with type + SizedBox( + width: _nodeColumnWidth(extraPadding: 20), + child: Row( + children: [ + RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), + Text( + node.nodeTypeLabel, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _discMarkerColor, + ), + ), + ], + ), + ), + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: node.localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), + ), + ), + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '${node.localRssi}', + color: rssiColor, + ), + ), + ), + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: node.remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), ), ), ); @@ -2376,7 +2877,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false}) { + void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -2410,25 +2911,33 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Header with icon badge (containing ID) and name Row( children: [ - // Icon badge with ID (mirrors map marker) - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: iconColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - alignment: Alignment.center, - child: Text( - repeater.id, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, + // Icon badge with hex ID (mirrors map marker) + Builder(builder: (context) { + final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final isLongId = displayId.length > 2; + return Container( + constraints: const BoxConstraints(minWidth: 44), + height: 44, + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 8) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: iconColor, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white, width: 2), ), - ), - ), + alignment: Alignment.center, + child: Text( + displayId, + style: TextStyle( + fontSize: isLongId ? 13 : 16, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ); + }), const SizedBox(width: 12), Expanded( child: Text( diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 31ba933..aa760b4 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -221,6 +221,8 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'RX Received', PingEventType.discSuccess => 'Discovery Success', PingEventType.discFail => 'Discovery Failed', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Failed', }; final eventDescription = switch (marker.type) { @@ -229,6 +231,8 @@ class InteractiveNoiseFloorChartState extends State PingEventType.rx => 'Received passive observation', PingEventType.discSuccess => 'Discovery got response', PingEventType.discFail => 'Discovery got no response', + PingEventType.traceSuccess => 'Trace got response from target', + PingEventType.traceFail => 'Trace got no response from target', }; final hasLocation = marker.latitude != null && marker.longitude != null; @@ -848,7 +852,8 @@ class InteractiveNoiseFloorChartState extends State _legendItem(context, Colors.red, 'TX Fail'), _legendItem(context, Colors.blue, 'RX'), _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.grey, 'DISC Fail'), + _legendItem(context, Colors.cyan, 'Trace Success'), + _legendItem(context, Colors.grey, 'No Response'), ], ); } diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart new file mode 100644 index 0000000..b54aec5 --- /dev/null +++ b/lib/widgets/offline_mode_toggle.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/app_state_provider.dart'; + +/// Shared offline mode toggle widget +/// Compact button style — icon + label, tappable to toggle with confirmation +class OfflineModeToggle extends StatelessWidget { + const OfflineModeToggle({super.key}); + + /// Handle offline mode toggle with progress dialog when connected + static Future handleOfflineModeToggle( + BuildContext context, + AppStateProvider appState, + bool currentOfflineMode, + bool isConnected, + ) async { + final newMode = !currentOfflineMode; + + // Always show confirmation dialog + final confirmed = await _showConfirmDialog(context, newMode); + if (confirmed != true || !context.mounted) return; + + // If connected, show progress dialog during mode switch + if (isConnected) { + final statusText = newMode + ? 'Switching to offline mode...' + : 'Switching to online mode...'; + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + statusText, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + + // Perform the mode switch + final result = await appState.setOfflineMode(newMode); + + // Close the progress dialog (check if context is still valid) + if (context.mounted) { + Navigator.of(context).pop(); + } + + // Show error dialog if switch failed + if (!result.success && context.mounted) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Mode Switch Failed'), + content: Text( + result.error ?? 'An unknown error occurred', + style: const TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } else { + // Not connected - simple toggle without dialog + await appState.setOfflineMode(newMode); + } + } + + /// Show confirmation dialog explaining what the mode does + static Future _showConfirmDialog(BuildContext context, bool switchingToOffline) { + final title = switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; + final icon = switchingToOffline ? Icons.cloud_off : Icons.cloud_done; + final iconColor = switchingToOffline ? Colors.orange : Colors.green; + final description = switchingToOffline + ? 'Wardrive data will be saved locally on your device instead of uploading to MeshMapper.\n\n' + 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' + 'You can upload saved data later from the Settings tab.' + : 'Wardrive data will be uploaded to MeshMapper immediately as you drive.\n\n' + 'This requires an active internet connection.'; + final confirmLabel = switchingToOffline ? 'Go Offline' : 'Go Online'; + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(icon, color: iconColor, size: 22), + const SizedBox(width: 8), + Flexible(child: Text(title)), + ], + ), + content: Text( + description, + style: const TextStyle(fontSize: 14, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: switchingToOffline + ? FilledButton.styleFrom(backgroundColor: Colors.orange) + : null, + child: Text(confirmLabel), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final appState = context.watch(); + final offlineMode = appState.offlineMode; + final isConnected = appState.isConnected; + + final color = offlineMode + ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) + : (isDark ? Colors.green.shade400 : Colors.green.shade700); + final bgColor = offlineMode + ? Colors.orange.withValues(alpha: 0.15) + : Colors.green.withValues(alpha: 0.15); + final borderColor = offlineMode + ? Colors.orange.withValues(alpha: 0.4) + : Colors.green.withValues(alpha: 0.4); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => handleOfflineModeToggle(context, appState, offlineMode, isConnected), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + offlineMode ? Icons.cloud_off : Icons.cloud_queue, + size: 18, + color: color, + ), + const SizedBox(width: 8), + Text( + offlineMode ? 'Go Online' : 'Go Offline', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index ce7c670..805417d 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; +import 'repeater_picker_sheet.dart'; /// Modern ping control panel with icon-based buttons and animated status class PingControls extends StatelessWidget { @@ -22,6 +23,7 @@ class PingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode @@ -34,6 +36,7 @@ class PingControls extends StatelessWidget { final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; // Last ping was skipped (e.g. distance) final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -108,7 +111,7 @@ class PingControls extends StatelessWidget { ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow onPressed: () => _sendPing(context, appState), @@ -145,7 +148,7 @@ class PingControls extends StatelessWidget { : rxWindowActive ? 'Listening ${rxWindowRemaining}s' // TX RX window : autoPingWaiting - ? 'Next ping ${autoPingRemaining}s' + ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next ping ${autoPingRemaining}s') : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') : rxWindowActive ? 'Cooldown ${rxWindowRemaining}s' @@ -157,7 +160,7 @@ class PingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), showCooldown: false, @@ -179,7 +182,7 @@ class PingControls extends StatelessWidget { ? (discoveryWindowActive ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window : autoPingWaiting - ? 'Next Disc ${autoPingRemaining}s' // Waiting for next discovery + ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery : 'Passive Mode') // Initial state before first discovery : isTxModeRunning || isPendingDisable ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping @@ -191,7 +194,7 @@ class PingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases @@ -224,15 +227,11 @@ class PingControls extends StatelessWidget { else const SizedBox(height: 8), - // Offline Mode and Sound toggles row - const Row( - children: [ - // Offline Mode toggle (expanded) - Expanded(child: _OfflineModeToggle()), - SizedBox(width: 8), - // Sound toggle (compact, right side) - _SoundToggle(), - ], + // Targeted Ping controls + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, ), ], ); @@ -445,370 +444,245 @@ class _ActionButtonState extends State<_ActionButton> } } -/// Compact sound notification toggle - matches height of _OfflineModeToggle -class _SoundToggle extends StatelessWidget { - const _SoundToggle(); +/// Targeted Ping controls - hex text field + start/stop button +class _TargetedPingSection extends StatefulWidget { + final bool isAnyModeRunning; + final bool cooldownActive; + final int cooldownRemaining; + final bool compact; + + const _TargetedPingSection({ + required this.isAnyModeRunning, + required this.cooldownActive, + required this.cooldownRemaining, + this.compact = false, + }); @override - Widget build(BuildContext context) { - final appState = context.watch(); - final soundEnabled = appState.isSoundEnabled; - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => appState.toggleSoundEnabled(), - borderRadius: BorderRadius.circular(10), - child: Container( - // Match _OfflineModeToggle: padding 10v + icon container (6+18+6) + text adds more height - // _OfflineModeToggle content: icon 30px, text column ~34px, toggle 26px - // Use same padding and let IntrinsicHeight from Row handle matching - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - soundEnabled ? Icons.volume_up : Icons.volume_off, - size: 18, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - // Add text column to match offline mode toggle height - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sound', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: soundEnabled - ? (isDark ? Colors.blue.shade300 : Colors.blue.shade800) - : colorScheme.onSurface, - ), - ), - Text( - soundEnabled ? 'On' : 'Off', - style: TextStyle( - fontSize: 11, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade600) - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } + State<_TargetedPingSection> createState() => _TargetedPingSectionState(); } -/// Compact offline mode toggle matching app design language -class _OfflineModeToggle extends StatelessWidget { - const _OfflineModeToggle(); - - // Offline mode is now enabled - static const bool _isEnabled = true; - - /// Handle offline mode toggle with progress dialog when connected - static Future _handleOfflineModeToggle( - BuildContext context, - AppStateProvider appState, - bool currentOfflineMode, - bool isConnected, - ) async { - final newMode = !currentOfflineMode; - - // If connected, show progress dialog during mode switch - if (isConnected) { - final statusText = newMode - ? 'Switching to offline mode...' - : 'Switching to online mode...'; - - // Show non-dismissible progress dialog - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => PopScope( - canPop: false, - child: AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - statusText, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - ), - ], - ), - ), - ), - ); - - // Perform the mode switch - final result = await appState.setOfflineMode(newMode); +class _TargetedPingSectionState extends State<_TargetedPingSection> { + final _controller = TextEditingController(); + bool _isStarting = false; - // Close the progress dialog (check if context is still valid) - if (context.mounted) { - Navigator.of(context).pop(); + @override + void initState() { + super.initState(); + // Restore any previously set target ID + WidgetsBinding.instance.addPostFrameCallback((_) { + final appState = context.read(); + final existing = appState.targetRepeaterId; + if (existing != null && existing.isNotEmpty && _controller.text != existing) { + _controller.text = existing; } + }); + } - // Show error dialog if switch failed - if (!result.success && context.mounted) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Mode Switch Failed'), - content: Text( - result.error ?? 'An unknown error occurred', - style: const TextStyle(fontSize: 14), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } - } else { - // Not connected - simple toggle without dialog - await appState.setOfflineMode(newMode); - } + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _showRepeaterPicker() async { + final appState = context.read(); + final repeater = await showRepeaterPicker(context); + if (repeater == null || !mounted) return; + + final maxLen = appState.traceHopBytes * 2; + final trimmed = repeater.hexId.length >= maxLen + ? repeater.hexId.substring(0, maxLen).toUpperCase() + : repeater.hexId.toUpperCase(); + _controller.text = trimmed; + appState.setTargetRepeaterId(trimmed); + setState(() {}); } @override Widget build(BuildContext context) { + final appState = context.watch(); + final isTargetedRunning = appState.isTargetedModeRunning; + final maxLen = appState.traceHopBytes * 2; final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - // When disabled, always show as "off" state - if (!_isEnabled) { - return Opacity( - opacity: 0.5, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.cloud_queue, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and "Coming soon" - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colorScheme.onSurfaceVariant, - ), - ), - Text( - 'Coming soon', - style: TextStyle( - fontSize: 11, - fontStyle: FontStyle.italic, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - // Toggle indicator (always off) - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1), // slate-600/300 - ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ); + + // Sync controller when provider clears target (e.g. trace bytes changed) + if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { + _controller.clear(); } - // Original implementation when enabled - final appState = context.watch(); - final offlineMode = appState.offlineMode; - final offlinePingCount = appState.offlinePingCount; - final isConnected = appState.isConnected; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _handleOfflineModeToggle(context, appState, offlineMode, isConnected), + // Determine if the start button should be enabled + final hexText = _controller.text.trim(); + final isValidHex = hexText.isNotEmpty && + hexText.length == maxLen && + RegExp(r'^[0-9a-fA-F]+$').hasMatch(hexText); + final canStart = isValidHex && + !widget.isAnyModeRunning && + !isTargetedRunning && + !widget.cooldownActive && + appState.isConnected; + + // Status text for when targeted mode is running + String? statusText; + if (isTargetedRunning) { + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryRemaining = appState.discoveryWindowTimer.remainingSec; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + + if (discoveryWindowActive) { + statusText = 'Listening ${discoveryRemaining}s'; + } else if (autoPingWaiting) { + statusText = appState.autoPingTimer.skipReason != null + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s'; + } + } + + final isEnabled = (canStart || isTargetedRunning) && !_isStarting; + final buttonColor = (isTargetedRunning || _isStarting) + ? const Color(0xFF22C55E) // green-500 when running/starting + : Colors.cyan; + final effectiveColor = isEnabled ? buttonColor : colorScheme.onSurfaceVariant; + + return Container( + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - offlineMode ? Icons.cloud_off : Icons.cloud_queue, - size: 18, - color: offlineMode - ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and count - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: offlineMode - ? (isDark ? Colors.orange.shade300 : Colors.orange.shade800) - : colorScheme.onSurface, - ), - ), - if (offlineMode && offlinePingCount > 0) - Text( - '$offlinePingCount pings saved locally', - style: TextStyle( - fontSize: 11, - color: isDark ? Colors.orange.shade400 : Colors.orange.shade600, - ), - ) - else - Text( - offlineMode - ? 'Data saved locally' - : 'Uploads immediately', + border: Border.all( + color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), + width: isTargetedRunning ? 1.5 : 1, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Targeted button + Expanded( + child: GestureDetector( + onTap: isEnabled + ? () async { + HapticFeedback.lightImpact(); + if (!isTargetedRunning) { + setState(() => _isStarting = true); + appState.setTargetRepeaterId(_controller.text.trim().toUpperCase()); + } + await appState.toggleAutoPing(AutoMode.targeted); + if (mounted) setState(() => _isStarting = false); + } + : null, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Icon( + Icons.route, + size: 18, + color: effectiveColor, + ), + if (!widget.compact) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + _isStarting + ? 'Starting...' + : isTargetedRunning + ? (statusText ?? 'Stop') + : widget.cooldownActive + ? 'Cooldown ${widget.cooldownRemaining}s' + : 'Trace Mode', style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, + fontSize: 13, + fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, + color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), + overflow: TextOverflow.ellipsis, ), + ), ], - ), + ], ), - // Toggle indicator - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: offlineMode - ? (isDark ? Colors.orange.shade600 : Colors.orange.shade500) - : (isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1)), // slate-600/300 + ), + ), + const SizedBox(width: 8), + // Hex text field + SizedBox( + width: 80, + child: TextField( + controller: _controller, + enabled: !isTargetedRunning, + maxLength: maxLen, + textCapitalization: TextCapitalization.characters, + style: TextStyle( + fontSize: 14, + fontFamily: 'monospace', + color: isTargetedRunning + ? colorScheme.onSurfaceVariant + : colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', + hintStyle: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - alignment: offlineMode - ? Alignment.centerRight - : Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), + counterText: '', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), ), - ], + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')), + _UpperCaseTextFormatter(), + ], + onChanged: (value) { + appState.setTargetRepeaterId(value.trim().toUpperCase()); + setState(() {}); + }, + ), ), - ), + const SizedBox(width: 6), + // Choose repeater button + SizedBox( + width: 32, + height: 32, + child: IconButton( + icon: Icon( + Icons.list, + size: 18, + color: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + ), + onPressed: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? _showRepeaterPicker + : null, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + tooltip: 'Choose repeater', + ), + ), + ], ), ); } } +/// Text formatter that converts input to uppercase +class _UpperCaseTextFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return TextEditingValue( + text: newValue.text.toUpperCase(), + selection: newValue.selection, + ); + } +} + /// Compact ping controls for minimized panel view /// Shows 3 small horizontal pill buttons in a row /// Active button expands to show context (e.g., "Listening 5s") @@ -820,7 +694,7 @@ class CompactPingControls extends StatefulWidget { } /// Tracks which button should stay expanded during cooldown -enum _LastActiveButton { none, sendPing, activeMode, passiveMode } +enum _LastActiveButton { none, sendPing, activeMode, passiveMode, targeted } class _CompactPingControlsState extends State { // Static so it persists across widget rebuilds (e.g., expand/minimize panel) @@ -837,6 +711,7 @@ class _CompactPingControlsState extends State { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -849,6 +724,7 @@ class _CompactPingControlsState extends State { final isPingInProgress = appState.isPingInProgress; final autoPingWaiting = appState.autoPingTimer.isRunning; final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; @@ -873,9 +749,11 @@ class _CompactPingControlsState extends State { _lastActiveButton = _LastActiveButton.activeMode; } else if (passiveModeCurrentlyActive) { _lastActiveButton = _LastActiveButton.passiveMode; + } else if (isTargetedRunning) { + _lastActiveButton = _LastActiveButton.targeted; } // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive) { + if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive && !isTargetedRunning) { _lastActiveButton = _LastActiveButton.none; } @@ -890,25 +768,36 @@ class _CompactPingControlsState extends State { (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + final sendPingEnabled = canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; final sendPingShowColor = sendPingEnabled || sendPingActive; - final activeModeEnabled = !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); + final activeModeEnabled = !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); final activeModeActive = isPendingDisable || isTxModeRunning; final activeModeShowColor = activeModeEnabled || activeModeActive; - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet); final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); final passiveModeShowColor = passiveModeEnabled || passiveModeActive; + // Trace Mode (only relevant when a repeater ID has been entered) + final hasTargetRepeaterId = appState.targetRepeaterId != null && appState.targetRepeaterId!.isNotEmpty; + final targetedCurrentlyActive = isTargetedRunning; + final traceModeExpanded = targetedCurrentlyActive || + (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); + final traceModeEnabled = hasTargetRepeaterId && !isTxModeRunning && !isPassiveModeRunning && + !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && + !manualCooldownActive && appState.isConnected && prefs.externalAntennaSet && isPowerSet; + final traceModeActive = isTargetedRunning; + final traceModeShowColor = traceModeEnabled || traceModeActive; + // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded; + final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded || traceModeExpanded; // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor; + final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor && (!hasTargetRepeaterId || !traceModeShowColor); // Build the buttons final sendPingButton = _CompactActionButton( @@ -953,6 +842,7 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: activeModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, discoveryWindowActive: discoveryWindowActive, discoveryWindowRemaining: discoveryWindowRemaining, ), @@ -986,6 +876,7 @@ class _CompactPingControlsState extends State { cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, isExpandedDuringCooldown: passiveModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, ), color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 @@ -1003,6 +894,40 @@ class _CompactPingControlsState extends State { onPressed: () => _toggleRxAuto(context, appState), ); + // Build trace mode button (only used when hasTargetRepeaterId) + final traceModeButton = _CompactActionButton( + icon: Icons.route, + label: _getTraceModeLabel( + isTargetedRunning: isTargetedRunning, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + showFullText: traceModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: traceModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, + ), + color: isTargetedRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF06B6D4), // cyan-500 + enabled: traceModeEnabled || isTargetedRunning, + isActive: traceModeActive, + isExpanded: traceModeExpanded, + progress: discoveryWindowActive && isTargetedRunning + ? appState.discoveryWindowTimer.progress + : autoPingWaiting && isTargetedRunning + ? appState.autoPingTimer.progress + : cooldownActive && _lastActiveButton == _LastActiveButton.targeted + ? appState.cooldownTimer.progress + : null, + onPressed: () { + HapticFeedback.lightImpact(); + appState.toggleAutoPing(AutoMode.targeted); + }, + ); + // Layout logic: // - If button is expanded (including during cooldown): stays big // - If no button is expanded: all colored buttons share space equally @@ -1034,6 +959,17 @@ class _CompactPingControlsState extends State { Expanded(child: passiveModeButton) else passiveModeButton, + + // Trace Mode (only shown when a repeater ID has been entered) + if (hasTargetRepeaterId) ...[ + const SizedBox(width: 6), + if (traceModeExpanded) + Expanded(child: traceModeButton) + else if (!anyExpanded && (traceModeShowColor || allDisabled)) + Expanded(child: traceModeButton) + else + traceModeButton, + ], ], ); } @@ -1074,6 +1010,7 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, bool discoveryWindowActive = false, int discoveryWindowRemaining = 0, }) { @@ -1086,7 +1023,7 @@ class _CompactPingControlsState extends State { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1107,10 +1044,37 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, }) { if (isPassiveModeRunning) { if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + } + // Show cooldown if this button caused it + if (cooldownActive && isExpandedDuringCooldown) { + return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + } + return null; + } + + /// Get label for Trace Mode button + /// When showFullText is true: "Listening 5s", when false: "5s" + String? _getTraceModeLabel({ + required bool isTargetedRunning, + required bool discoveryWindowActive, + required int discoveryWindowRemaining, + required bool autoPingWaiting, + required int autoPingRemaining, + required bool showFullText, + required bool cooldownActive, + required int cooldownRemaining, + required bool isExpandedDuringCooldown, + required bool isSkipped, + }) { + if (isTargetedRunning) { + if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; + return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1163,6 +1127,7 @@ class LandscapePingControls extends StatelessWidget { final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; @@ -1186,11 +1151,6 @@ class LandscapePingControls extends StatelessWidget { final prefs = appState.preferences; final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - // Antenna selector - final soundEnabled = appState.isSoundEnabled; - final offlineMode = appState.offlineMode; - final isConnected = appState.isConnected; - return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1213,7 +1173,7 @@ class LandscapePingControls extends StatelessWidget { icon: Icons.cell_tower, tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && + enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, countdown: isPingSending @@ -1242,7 +1202,7 @@ class LandscapePingControls extends StatelessWidget { : isTxModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), + enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), isActive: isPendingDisable || isTxModeRunning, countdown: isTxModeRunning ? (discoveryWindowActive @@ -1268,7 +1228,7 @@ class LandscapePingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && + enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && prefs.externalAntennaSet && isPowerSet), isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), @@ -1286,34 +1246,12 @@ class LandscapePingControls extends StatelessWidget { ), const SizedBox(height: 10), - // Compact row for toggles - Row( - children: [ - Expanded( - child: _LandscapeToggle( - icon: offlineMode ? Icons.cloud_off : Icons.cloud_queue, - label: 'Offline', - isOn: offlineMode, - color: Colors.orange, - onTap: () => _OfflineModeToggle._handleOfflineModeToggle( - context, - appState, - offlineMode, - isConnected, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _LandscapeToggle( - icon: soundEnabled ? Icons.volume_up : Icons.volume_off, - label: 'Sound', - isOn: soundEnabled, - color: Colors.blue, - onTap: () => appState.toggleSoundEnabled(), - ), - ), - ], + // Targeted Ping controls (Trace Mode) + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + compact: true, ), ], ); @@ -1612,70 +1550,6 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> } /// Compact toggle button for landscape panel -class _LandscapeToggle extends StatelessWidget { - final IconData icon; - final String label; - final bool isOn; - final Color color; - final VoidCallback onTap; - - const _LandscapeToggle({ - required this.icon, - required this.label, - required this.isOn, - required this.color, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final activeColor = isOn ? color : colorScheme.onSurfaceVariant; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Container( - height: 38, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: isOn - ? activeColor.withValues(alpha: 0.12) - : colorScheme.onSurface.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isOn - ? activeColor.withValues(alpha: 0.35) - : colorScheme.outline.withValues(alpha: 0.15), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 16, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isOn ? FontWeight.w600 : FontWeight.w500, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ); - } -} - /// Compact action button for minimized panel - horizontal pill layout /// Supports expanding to show label when active class _CompactActionButton extends StatefulWidget { diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index 4c50483..10a909e 100644 --- a/lib/widgets/regional_config_card.dart +++ b/lib/widgets/regional_config_card.dart @@ -6,18 +6,26 @@ class RegionalConfigCard extends StatelessWidget { final String? zoneName; final String? zoneCode; final List channels; + final String? scope; final bool isOfflineMode; + final bool compact; const RegionalConfigCard({ super.key, this.zoneName, this.zoneCode, this.channels = const [], + this.scope, this.isOfflineMode = false, + this.compact = false, }); @override Widget build(BuildContext context) { + if (compact) { + return _buildCompact(context); + } + // When offline mode is enabled, show "-" for zone fields final displayZoneName = isOfflineMode ? '-' : (zoneName ?? 'Not configured'); final displayZoneCode = isOfflineMode ? '-' : zoneCode; @@ -72,6 +80,16 @@ class RegionalConfigCard extends StatelessWidget { isOffline: isOfflineMode), const SizedBox(height: 12), + // Scope + _buildInfoRow( + context, + Icons.filter_alt, + 'Scope', + isOfflineMode ? '-' : (scope ?? 'Global'), + isOffline: isOfflineMode, + ), + const SizedBox(height: 12), + // Channels header _buildInfoRow(context, Icons.tag, 'RX Channels', null), const SizedBox(height: 8), @@ -96,6 +114,93 @@ class RegionalConfigCard extends StatelessWidget { ); } + /// Compact mode: "Regional Settings" header, scope row, channel chips + Widget _buildCompact(BuildContext context) { + final displayZone = isOfflineMode ? null : zoneName; + final displayScope = isOfflineMode ? '-' : (scope ?? 'Global'); + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.public, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Regional Settings', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (displayZone != null) + Text( + displayZone, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Scope row + _buildCompactRow(context, 'Scope', [ + _buildChannelChip(context, displayScope, isDefault: true), + ]), + const SizedBox(height: 8), + + // Channels row + _buildCompactRow(context, 'Channels', [ + _buildChannelChip(context, 'Public', isDefault: true), + _buildChannelChip(context, '#wardriving', isDefault: true), + if (!isOfflineMode) + ...channels.map((c) => _buildChannelChip(context, c)), + ]), + ], + ), + ), + ); + } + + /// Compact labeled row: small label on left, chips on right + Widget _buildCompactRow(BuildContext context, String label, List chips) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 70, + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: chips, + ), + ), + ], + ); + } + Widget _buildInfoRow(BuildContext context, IconData icon, String label, String? value, {bool isOffline = false}) { return Row( @@ -118,8 +223,12 @@ class RegionalConfigCard extends StatelessWidget { } Widget _buildChannelChip(BuildContext context, String name, {bool isDefault = false}) { - // Public channel doesn't use # prefix + // Public channel doesn't use # prefix; scope/plain values pass through as-is final displayName = name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); + // If it doesn't look like a channel name, show raw value (e.g. scope "Global") + final isChannel = name.startsWith('#') || name == 'Public'; + final label = isChannel ? displayName : name; + return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -134,11 +243,10 @@ class RegionalConfigCard extends StatelessWidget { ), ), child: Text( - displayName, + label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - // Use onPrimaryContainer for proper contrast on primaryContainer background color: isDefault ? Colors.grey : Theme.of(context).colorScheme.onPrimaryContainer, ), ), diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index b6c61ba..0088da5 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -10,11 +10,11 @@ import '../utils/distance_formatter.dart'; /// A styled repeater ID text with a dotted underline hint that it's tappable. /// -/// Displays the 2-char hex repeater ID in monospace style. Use together with -/// [RepeaterIdChip.showRepeaterPopup] on the parent row's `InkWell` so the -/// entire row is the tap target. +/// Displays the hex repeater ID (2/4/6 chars) in monospace style. Use together +/// with [RepeaterIdChip.showRepeaterPopup] on the parent row's `InkWell` so +/// the entire row is the tap target. class RepeaterIdChip extends StatelessWidget { - /// The 2-char hex repeater ID (e.g., "4e") + /// The hex repeater ID (e.g., "4E", "4F5D", "4F5D82") final String repeaterId; /// Font size for the ID text (11 for log screens, 13 for map popups) @@ -32,13 +32,22 @@ class RepeaterIdChip extends StatelessWidget { @override Widget build(BuildContext context) { + // Scale font size down for longer IDs + final effectiveFontSize = repeaterId.length > 4 + ? fontSize - 2.0 // 6-char IDs (3-byte) + : repeaterId.length > 2 + ? fontSize - 1.0 // 4-char IDs (2-byte) + : fontSize; // 2-char IDs (1-byte) + final child = Row( mainAxisSize: MainAxisSize.min, children: [ Text( repeaterId, + softWrap: false, + overflow: TextOverflow.clip, style: TextStyle( - fontSize: fontSize, + fontSize: effectiveFontSize, fontWeight: FontWeight.w600, fontFamily: 'monospace', color: Theme.of(context).colorScheme.onSurface, @@ -124,10 +133,11 @@ class RepeaterIdChip extends StatelessWidget { }); } + final regionOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, position: position)) + .map((r) => _buildRepeaterRow(context, r, position: position, regionHopBytesOverride: regionOverride)) .toList(), ); } @@ -188,6 +198,7 @@ class RepeaterIdChip extends StatelessWidget { BuildContext context, Repeater repeater, { Position? position, + int? regionHopBytesOverride, }) { final isActive = repeater.isActive; final badgeColor = isActive ? Colors.green : Colors.grey; @@ -218,27 +229,8 @@ class RepeaterIdChip extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ - // Colored circle badge - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: badgeColor, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - repeater.hexId.length >= 2 - ? repeater.hexId.substring(0, 2).toUpperCase() - : repeater.hexId.toUpperCase(), - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: 'monospace', - ), - ), - ), + // Colored badge — circle for short IDs, pill for longer + _buildHexBadge(repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), const SizedBox(width: 12), // Repeater name + distance subtitle Expanded( @@ -302,4 +294,31 @@ class RepeaterIdChip extends StatelessWidget { ), ); } + + /// Build a hex ID badge — circle for 2-char, pill for longer IDs + static Widget _buildHexBadge(String displayId, Color color) { + final isLong = displayId.length > 2; + + return Container( + constraints: const BoxConstraints(minWidth: 28), + height: 28, + padding: isLong + ? const EdgeInsets.symmetric(horizontal: 5) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Text( + displayId, + style: TextStyle( + fontSize: displayId.length > 4 ? 8 : 10, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ); + } } diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart new file mode 100644 index 0000000..a10fa2d --- /dev/null +++ b/lib/widgets/repeater_picker_sheet.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:provider/provider.dart'; + +import '../models/repeater.dart'; +import '../providers/app_state_provider.dart'; +import '../services/gps_service.dart'; +import '../utils/distance_formatter.dart'; + +/// Show a bottom sheet repeater picker and return the selected repeater. +Future showRepeaterPicker(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.95, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => _RepeaterPickerBody( + scrollController: scrollController, + ), + ), + ); +} + +class _RepeaterPickerBody extends StatefulWidget { + final ScrollController scrollController; + + const _RepeaterPickerBody({required this.scrollController}); + + @override + State<_RepeaterPickerBody> createState() => _RepeaterPickerBodyState(); +} + +class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _filterAndSort(List repeaters, Position? position) { + List filtered; + if (_query.isNotEmpty) { + final q = _query.toLowerCase(); + filtered = repeaters + .where((r) => + r.name.toLowerCase().contains(q) || + r.hexId.toLowerCase().startsWith(q)) + .toList(); + } else { + filtered = List.of(repeaters); + } + + // Sort: active first, then by distance (if GPS), then alphabetically + filtered.sort((a, b) { + // Active first + if (a.isActive != b.isActive) return a.isActive ? -1 : 1; + // By distance if GPS available + if (position != null) { + final distA = GpsService.distanceBetween( + position.latitude, position.longitude, a.lat, a.lon, + ); + final distB = GpsService.distanceBetween( + position.latitude, position.longitude, b.lat, b.lon, + ); + return distA.compareTo(distB); + } + // Alphabetically + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + + return filtered; + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final repeaters = appState.repeaters; + final position = appState.currentPosition; + final isImperial = appState.preferences.isImperial; + final colorScheme = Theme.of(context).colorScheme; + + final filtered = _filterAndSort(repeaters, position); + + return Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.cell_tower, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Select Repeater', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + // Search field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search by name or hex ID...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ) + : null, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onChanged: (value) => setState(() => _query = value), + ), + ), + // Count label + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Showing ${filtered.length} of ${repeaters.length} repeaters', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const Divider(height: 1), + // List + Expanded( + child: filtered.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + repeaters.isEmpty + ? 'No repeaters available' + : 'No repeaters match your search', + style: TextStyle( + fontSize: 14, + fontStyle: FontStyle.italic, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ListView.builder( + controller: widget.scrollController, + itemCount: filtered.length, + itemBuilder: (context, index) { + final r = filtered[index]; + return _RepeaterTile( + repeater: r, + position: position, + isImperial: isImperial, + onTap: () => Navigator.pop(context, r), + ); + }, + ), + ), + ], + ); + } +} + +class _RepeaterTile extends StatelessWidget { + final Repeater repeater; + final Position? position; + final bool isImperial; + final VoidCallback onTap; + + const _RepeaterTile({ + required this.repeater, + required this.position, + required this.isImperial, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isActive = repeater.isActive; + final badgeColor = isActive ? Colors.green : Colors.grey; + + // Distance text + String? distanceText; + if (position != null) { + final meters = GpsService.distanceBetween( + position!.latitude, position!.longitude, repeater.lat, repeater.lon, + ); + if (meters < 1000) { + distanceText = formatMeters(meters, isImperial: isImperial); + } else { + distanceText = + formatKilometers(meters / 1000, isImperial: isImperial); + } + } + + // Always show 4-byte (8-char) hex ID for identification + final displayHex = repeater.hexId.length >= 8 + ? repeater.hexId.substring(0, 8).toUpperCase() + : repeater.hexId.toUpperCase(); + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + // Hex badge + Container( + constraints: const BoxConstraints(minWidth: 28), + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Text( + displayHex, + style: TextStyle( + fontSize: displayHex.length > 4 ? 8 : 10, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 12), + // Name + distance + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + repeater.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + if (distanceText != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.near_me, + size: 10, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 3), + Text( + distanceText, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Active/Stale chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: badgeColor.withValues(alpha: 0.4)), + ), + child: Text( + isActive ? 'Active' : 'Stale', + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index c0904ff..563d28f 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -57,7 +57,7 @@ class _StatusBarState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: const EdgeInsets.all(20), + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -158,6 +158,9 @@ class _StatusBarState extends State { case 'disc': return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); + case 'trace': + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -262,6 +265,16 @@ class _StatusBarState extends State { const SizedBox(width: 8), + // Trace count chip (animated) + _AnimatedStatChip( + icon: Icons.route, + value: appState.pingStats.traceCount, + color: Colors.cyan, + onTap: () => _showInfoPopup(context, 'trace'), + ), + + const SizedBox(width: 8), + // Uploaded count chip (animated) _AnimatedStatChip( icon: Icons.cloud_done, diff --git a/lib/widgets/upload_logs_dialog.dart b/lib/widgets/upload_logs_dialog.dart new file mode 100644 index 0000000..4ba980e --- /dev/null +++ b/lib/widgets/upload_logs_dialog.dart @@ -0,0 +1,825 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../providers/app_state_provider.dart'; +import '../services/debug_file_logger.dart'; +import '../services/debug_submit_service.dart'; +import '../utils/constants.dart'; +import '../utils/debug_logger_io.dart'; + +/// Result of a log upload operation +class UploadLogsResult { + final bool success; + final int uploadedCount; + final int failedCount; + final String? errorMessage; + + const UploadLogsResult({ + required this.success, + this.uploadedCount = 0, + this.failedCount = 0, + this.errorMessage, + }); +} + +/// Bottom sheet for uploading debug logs with a mandatory description +class UploadLogsSheet extends StatefulWidget { + final AppStateProvider appState; + final ScrollController scrollController; + + const UploadLogsSheet({ + super.key, + required this.appState, + required this.scrollController, + }); + + @override + State createState() => _UploadLogsSheetState(); +} + +class _UploadLogsSheetState extends State { + final _formKey = GlobalKey(); + final _descriptionController = TextEditingController(); + + final Set _selectedLogFiles = {}; + bool _isSubmitting = false; + String? _errorMessage; + + // Progress tracking + double _progress = 0.0; + String _progressStatus = ''; + int? _currentFile; + int? _totalFiles; + + // Uploadable log files + List _availableLogFiles = []; + bool _isLoadingFiles = true; + + @override + void initState() { + super.initState(); + _loadUploadableFiles(); + } + + Future _loadUploadableFiles() async { + try { + final files = await widget.appState.prepareDebugLogsForUpload(); + if (mounted) { + setState(() { + _availableLogFiles = files; + _isLoadingFiles = false; + // Select all by default + _selectedLogFiles.addAll(files.map((f) => f.path)); + }); + } + } catch (e) { + debugError('[DEBUG] Failed to load uploadable files: $e'); + if (mounted) { + setState(() { + _availableLogFiles = []; + _isLoadingFiles = false; + }); + } + } + } + + @override + void dispose() { + _descriptionController.dispose(); + super.dispose(); + } + + void _toggleFile(String path) { + setState(() { + if (_selectedLogFiles.contains(path)) { + _selectedLogFiles.remove(path); + } else { + _selectedLogFiles.add(path); + } + }); + } + + void _onProgressUpdate(BugReportProgress progress) { + if (mounted) { + setState(() { + _progress = progress.progress; + _progressStatus = progress.status; + _currentFile = progress.currentFile; + _totalFiles = progress.totalFiles; + }); + } + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + if (_selectedLogFiles.isEmpty) { + setState(() { + _errorMessage = 'Please select at least one log file to upload'; + }); + return; + } + + // Warn user about GPS data in debug logs + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Location Data Warning'), + content: const Text( + 'Debug logs may contain your approximate GPS coordinates ' + 'from your wardriving session. This location history will ' + 'be included in the uploaded files.\n\n' + 'Do you want to continue?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Continue'), + ), + ], + ), + ); + if (confirmed != true) return; + + setState(() { + _isSubmitting = true; + _errorMessage = null; + _progress = 0.0; + _progressStatus = 'Preparing logs...'; + _currentFile = null; + _totalFiles = null; + }); + + try { + // Rotate the current log file now that the user has committed to uploading + final freshFiles = await widget.appState.prepareDebugLogsForUpload(); + + // Build the upload list using the user's selection applied to the freshly rotated files. + // Selected paths from before rotation still match, plus any newly rotated file is included. + final selectedPaths = Set.from(_selectedLogFiles); + final filesToUpload = freshFiles + .where((f) => selectedPaths.contains(f.path)) + .toList(); + + // If the rotation produced a new file that wasn't in the original selection + // (i.e. the previously-active log that just got rotated), include it too + // since the user selected "all" initially and this file has new content. + final newFiles = freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); + if (newFiles.isNotEmpty && selectedPaths.length == _availableLogFiles.length) { + filesToUpload.addAll(newFiles); + } + + if (filesToUpload.isEmpty) { + if (mounted) { + setState(() { + _errorMessage = 'No log files to upload after preparation'; + _isSubmitting = false; + _progress = 0.0; + _progressStatus = ''; + }); + } + return; + } + + final service = DebugSubmitService(); + + final publicKey = widget.appState.devicePublicKey ?? + widget.appState.lastConnectedPublicKey ?? + 'not-connected'; + final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final userNotes = _descriptionController.text.trim(); + + int uploadedCount = 0; + int failedCount = 0; + final totalFiles = filesToUpload.length; + + for (int i = 0; i < totalFiles; i++) { + final file = filesToUpload[i]; + final progressBase = i / totalFiles; + final progressPerFile = 1.0 / totalFiles; + + _onProgressUpdate(BugReportProgress( + status: 'Uploading file ${i + 1} of $totalFiles...', + progress: progressBase, + currentFile: i + 1, + totalFiles: totalFiles, + )); + + final success = await service.uploadDebugFileOnly( + file: file, + deviceId: deviceName, + publicKey: publicKey, + appVersion: AppConstants.appVersion, + devicePlatform: DebugSubmitService.getDevicePlatform(), + userNotes: userNotes, + onProgress: (p) { + _onProgressUpdate(BugReportProgress( + status: p.status, + progress: (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), + currentFile: i + 1, + totalFiles: totalFiles, + )); + }, + ); + + if (success) { + uploadedCount++; + } else { + failedCount++; + } + } + + service.dispose(); + + if (!mounted) return; + + final result = UploadLogsResult( + success: uploadedCount > 0, + uploadedCount: uploadedCount, + failedCount: failedCount, + errorMessage: failedCount > 0 ? '$failedCount file(s) failed to upload' : null, + ); + + Navigator.of(context).pop(result); + } catch (e) { + debugError('[DEBUG] Upload logs error: $e'); + if (mounted) { + setState(() { + _errorMessage = 'Error: $e'; + _isSubmitting = false; + _progress = 0.0; + _progressStatus = ''; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (_isSubmitting) { + return _buildProgressView(theme); + } + + return Column( + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + const SizedBox(width: 12), + Text('Upload Logs', style: theme.textTheme.titleLarge), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + tooltip: 'Close', + ), + ], + ), + ), + + const Divider(height: 1), + + // Scrollable content + Expanded( + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.opaque, + child: Form( + key: _formKey, + child: ListView( + controller: widget.scrollController, + padding: const EdgeInsets.all(20), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + // Explanation text + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Upload your debug logs directly to the MeshMapper developers. ' + 'This helps us diagnose issues and improve the app.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Description field (mandatory) + _buildSectionLabel(theme, Icons.description, 'Description'), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Briefly describe why you\'re uploading these logs...', + alignLabelWithHint: true, + ), + maxLines: 3, + maxLength: 500, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'A description is required'; + } + if (value.trim().length < 10) { + return 'Please provide more detail (at least 10 characters)'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Log files section + _buildSectionLabel(theme, Icons.folder_open, 'Log Files'), + const SizedBox(height: 8), + + if (_isLoadingFiles) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Preparing log files...', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ) + else if (_availableLogFiles.isEmpty) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Text( + 'No log files available to upload', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ) + else + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Column( + children: [ + // Select all / deselect all header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Text( + '${_selectedLogFiles.length} of ${_availableLogFiles.length} selected', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + TextButton( + onPressed: () { + setState(() { + if (_selectedLogFiles.length == _availableLogFiles.length) { + _selectedLogFiles.clear(); + } else { + _selectedLogFiles.clear(); + _selectedLogFiles.addAll( + _availableLogFiles.map((f) => f.path), + ); + } + }); + }, + child: Text( + _selectedLogFiles.length == _availableLogFiles.length + ? 'Deselect All' + : 'Select All', + ), + ), + ], + ), + ), + Divider( + height: 1, + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + // File list + ...List.generate(_availableLogFiles.length, (index) { + final file = _availableLogFiles[index]; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isSelected = _selectedLogFiles.contains(file.path); + + String sizeDisplay; + final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + + return ListTile( + dense: true, + leading: Checkbox( + value: isSelected, + onChanged: _isSubmitting + ? null + : (_) => _toggleFile(file.path), + ), + title: Text( + filename, + style: const TextStyle(fontSize: 13), + ), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sizeDisplay, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + onTap: _isSubmitting ? null : () => _toggleFile(file.path), + ); + }), + ], + ), + ), + + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, + color: theme.colorScheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), + ), + ], + + SizedBox(height: MediaQuery.of(context).padding.bottom + 80), + ], + ), + ), + ), + ), + + // Sticky bottom action bar + Container( + padding: EdgeInsets.fromLTRB( + 20, + 12, + 20, + MediaQuery.of(context).padding.bottom + 12, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: _isSubmitting || _availableLogFiles.isEmpty + ? null + : _submit, + icon: const Icon(Icons.cloud_upload, size: 18), + label: Text( + _selectedLogFiles.isEmpty + ? 'Upload' + : 'Upload ${_selectedLogFiles.length} Log${_selectedLogFiles.length == 1 ? '' : 's'}', + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildProgressView(ThemeData theme) { + return Column( + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + const SizedBox(width: 12), + Text('Uploading...', style: theme.textTheme.titleLarge), + ], + ), + ), + + const Divider(height: 1), + + // Progress content + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Center( + child: SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + color: theme.colorScheme.primary, + ), + ), + ), + ), + const SizedBox(height: 32), + + Text( + _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + if (_totalFiles != null && _currentFile != null) + Text( + 'File $_currentFile of $_totalFiles', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + + SizedBox( + width: 250, + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: _progress, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + color: theme.colorScheme.primary, + minHeight: 8, + ), + ), + const SizedBox(height: 8), + Text( + '${(_progress * 100).toInt()}%', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + + Padding( + padding: EdgeInsets.fromLTRB( + 20, + 12, + 20, + MediaQuery.of(context).padding.bottom + 12, + ), + child: Text( + 'Please don\'t close this screen', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ); + } + + Widget _buildSectionLabel(ThemeData theme, IconData icon, String label) { + return Row( + children: [ + Icon(icon, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + + InputDecoration _buildInputDecoration( + ThemeData theme, { + String? hintText, + bool alignLabelWithHint = false, + }) { + return InputDecoration( + hintText: hintText, + alignLabelWithHint: alignLabelWithHint, + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.error, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ); + } +} + +/// Show the upload logs dialog and return the result +Future showUploadLogsDialog( + BuildContext context, + AppStateProvider appState, +) async { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => UploadLogsSheet( + appState: appState, + scrollController: scrollController, + ), + ), + ); +}