Open-source Flutter client for MeshCore LoRa mesh networking devices. Features: - BLE device scanning and connection - Nordic UART Service (NUS) integration - Material 3 design with system theme support - Provider-based state management - Placeholder screens for chat, contacts, and settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>chore/offband-rebrand
@ -0,0 +1,82 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.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/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
|
|
||||||
|
# Flutter plugin generated files
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
secrets.dart
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
**/ios/Pods/
|
||||||
|
**/ios/.symlinks/
|
||||||
|
**/ios/Flutter/Flutter.framework
|
||||||
|
**/ios/Flutter/Flutter.podspec
|
||||||
|
|
||||||
|
# Android
|
||||||
|
**/android/.gradle/
|
||||||
|
**/android/captures/
|
||||||
|
**/android/local.properties
|
||||||
|
**/android/.externalNativeBuild/
|
||||||
|
*.jks
|
||||||
|
keystore.properties
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
*.g.dart
|
||||||
|
*.freezed.dart
|
||||||
|
*.mocks.dart
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/settings.json
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
- platform: android
|
||||||
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
- platform: ios
|
||||||
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
- platform: linux
|
||||||
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
- platform: macos
|
||||||
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
- platform: web
|
||||||
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
- platform: windows
|
||||||
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- Core Flutter code is in `lib/`, with BLE protocol definitions in `lib/connector/meshcore_protocol.dart` and BLE transport/state in `lib/connector/meshcore_connector.dart`.
|
||||||
|
- UI lives in `lib/screens/` and `lib/widgets/`, models in `lib/models/`, tests in `test/`, and platform runners in `android/`, `ios/`, `macos/`, `linux/`, `windows/`, `web/`.
|
||||||
|
|
||||||
|
## BLE Frames & Protocol Notes
|
||||||
|
- Nordic UART Service (NUS) UUIDs: Service `6e400001-b5a3-f393-e0a9-e50e24dcca9e`, RX `6e400002-b5a3-f393-e0a9-e50e24dcca9e`, TX `6e400003-b5a3-f393-e0a9-e50e24dcca9e`.
|
||||||
|
- Discovery: scans for device name prefix `MeshCore-` and filters by `platformName`/`advertisementData.advName`.
|
||||||
|
- Frames are capped at `maxFrameSize = 172` bytes; byte 0 is the command/response/push code. I/O is `MeshCoreConnector.sendFrame` and `MeshCoreConnector.receivedFrames`.
|
||||||
|
- Command codes (to device): `cmdAppStart`=1, `cmdSendTxtMsg`=2, `cmdSendChannelTxtMsg`=3, `cmdGetContacts`=4, `cmdGetDeviceTime`=5, `cmdSetDeviceTime`=6, `cmdSendSelfAdvert`=7, `cmdSetAdvertName`=8, `cmdAddUpdateContact`=9, `cmdSyncNextMessage`=10, `cmdSetRadioParams`=11, `cmdSetRadioTxPower`=12, `cmdResetPath`=13, `cmdSetAdvertLatLon`=14, `cmdRemoveContact`=15, `cmdShareContact`=16, `cmdExportContact`=17, `cmdImportContact`=18, `cmdReboot`=19, `cmdSendLogin`=26, `cmdGetChannel`=31, `cmdSetChannel`=32, `cmdGetRadioSettings`=57.
|
||||||
|
- Response codes (from device): `respCodeOk`=0, `respCodeErr`=1, `respCodeContactsStart`=2, `respCodeContact`=3, `respCodeEndOfContacts`=4, `respCodeSelfInfo`=5, `respCodeSent`=6, `respCodeContactMsgRecv`=7, `respCodeChannelMsgRecv`=8, `respCodeCurrTime`=9, `respCodeNoMoreMessages`=10, `respCodeContactMsgRecvV3`=16, `respCodeChannelMsgRecvV3`=17, `respCodeChannelInfo`=18, `respCodeRadioSettings`=25.
|
||||||
|
- Push codes (async): `pushCodeAdvert`=0x80, `pushCodePathUpdated`=0x81, `pushCodeSendConfirmed`=0x82, `pushCodeMsgWaiting`=0x83, `pushCodeLoginSuccess`=0x85, `pushCodeLoginFail`=0x86, `pushCodeLogRxData`=0x88, `pushCodeNewAdvert`=0x8A.
|
||||||
|
- Device info: `cmdAppStart` triggers `respCodeSelfInfo` with tx power, pubkey, lat/lon, telemetry flags, radio params, and node name (see offsets in `lib/connector/meshcore_connector.dart`).
|
||||||
|
- Radio/time helpers: `cmdGetRadioSettings` → `respCodeRadioSettings`; `cmdGetDeviceTime` → `respCodeCurrTime`; `cmdSetDeviceTime` updates device time.
|
||||||
|
- Reboot: the UI sends `sendCliCommand('reboot')` (the raw `cmdReboot` code exists but no frame builder is wired in yet).
|
||||||
|
- Companion radio format: `cmdSendTxtMsg` expects `[cmd][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]` (no flags/full pubkey). CLI commands use `txtTypeCliData` in the same format, and the app maps `forceFlood` to attempt `3` when sending.
|
||||||
|
- Group text packets (`PAYLOAD_TYPE_GRP_TXT`): payload is `[channel_hash (1)][MAC (2)][encrypted data...]`. Decrypted data layout is `[timestamp x4][txt_type][text...]` where text is `"sender: message"` (see MeshCore `BaseChatMesh::sendGroupMessage`). Sender identity is not in the payload; use `PUSH_CODE_LOG_RX_DATA` raw packet path bytes for origin hash when available.
|
||||||
|
- Identity hash: `PATH_HASH_SIZE` is 1 byte; it is the prefix of the public key (see `Identity::copyHashTo`). Flooded packets append this hash to the path as they traverse hops. Self-identification via log data should compare sender name and presence of self pubkey prefix within the path bytes.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `~/flutter/bin/flutter pub get` installs dependencies (or `flutter pub get` if Flutter is on PATH).
|
||||||
|
- `~/flutter/bin/flutter run` launches the app; `~/flutter/bin/flutter build apk|ios` produces release builds.
|
||||||
|
- `~/flutter/bin/flutter analyze` and `~/flutter/bin/flutter test` run linting and tests.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- Follow `flutter_lints`, use `lowerCamelCase`/`UpperCamelCase`/`snake_case`, prefer `StatelessWidget` + `Consumer`, and use `const` constructors.
|
||||||
|
- Material widgets only; keep screens simple, handle disconnects by returning to the scanner, and avoid premature abstractions.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- Tests use `flutter_test`; add `*_test.dart` under `test/` and run `flutter test` before UI/protocol changes.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Keep commit subjects short and action-focused; PRs should describe behavior changes, link issues, include screenshots for UI changes, and call out BLE protocol changes explicitly.
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
# MeshCore Open - Flutter Client
|
||||||
|
|
||||||
|
Open-source Flutter client for MeshCore LoRa mesh networking devices.
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
~/flutter/bin/flutter pub get
|
||||||
|
|
||||||
|
# Run in debug mode
|
||||||
|
~/flutter/bin/flutter run
|
||||||
|
|
||||||
|
# Build Android APK
|
||||||
|
~/flutter/bin/flutter build apk
|
||||||
|
|
||||||
|
# Build iOS
|
||||||
|
~/flutter/bin/flutter build ios
|
||||||
|
|
||||||
|
# Run static analysis
|
||||||
|
~/flutter/bin/flutter analyze
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
~/flutter/bin/flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart # App entry point, MaterialApp setup with Provider
|
||||||
|
├── connector/
|
||||||
|
│ └── meshcore_connector.dart # BLE communication layer (MeshCoreConnector)
|
||||||
|
├── screens/
|
||||||
|
│ ├── scanner_screen.dart # BLE device scanning (home screen)
|
||||||
|
│ ├── device_screen.dart # Connected device hub with navigation
|
||||||
|
│ ├── chat_screen.dart # Chat interface (placeholder)
|
||||||
|
│ ├── contacts_screen.dart # Contacts list (placeholder)
|
||||||
|
│ └── settings_screen.dart # Device info and app settings
|
||||||
|
└── widgets/
|
||||||
|
└── device_tile.dart # Device list item with signal strength
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- **Provider** with `ChangeNotifier` pattern
|
||||||
|
- `MeshCoreConnector` is the central state holder for BLE connection
|
||||||
|
- Screens use `Consumer<MeshCoreConnector>` for reactive UI updates
|
||||||
|
|
||||||
|
### Theming
|
||||||
|
- Material 3 design (`useMaterial3: true`)
|
||||||
|
- System-based dark/light mode (`ThemeMode.system`)
|
||||||
|
- Blue color scheme seed
|
||||||
|
|
||||||
|
## BLE Protocol
|
||||||
|
|
||||||
|
### Nordic UART Service (NUS)
|
||||||
|
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
|
||||||
|
- **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device)
|
||||||
|
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
|
||||||
|
|
||||||
|
### Device Discovery
|
||||||
|
- Scans for devices with name prefix `MeshCore-`
|
||||||
|
- Filters by `platformName` or `advertisementData.advName`
|
||||||
|
|
||||||
|
### Connection States
|
||||||
|
```dart
|
||||||
|
enum MeshCoreConnectionState {
|
||||||
|
disconnected,
|
||||||
|
scanning,
|
||||||
|
connecting,
|
||||||
|
connected,
|
||||||
|
disconnecting,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frame I/O
|
||||||
|
- **Send**: `MeshCoreConnector.sendFrame(Uint8List data)`
|
||||||
|
- **Receive**: `MeshCoreConnector.receivedFrames` stream of `Uint8List`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| flutter_blue_plus | ^2.1.0 | BLE communication |
|
||||||
|
| provider | ^6.1.5+1 | State management |
|
||||||
|
| cupertino_icons | ^1.0.8 | iOS-style icons |
|
||||||
|
|
||||||
|
## Platform Configuration
|
||||||
|
|
||||||
|
### Android (`android/app/src/main/AndroidManifest.xml`)
|
||||||
|
- `BLUETOOTH`, `BLUETOOTH_ADMIN` (API 30 and below)
|
||||||
|
- `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE` (API 31+)
|
||||||
|
- `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` (for BLE scanning)
|
||||||
|
|
||||||
|
### iOS (`ios/Runner/Info.plist`)
|
||||||
|
- `NSBluetoothAlwaysUsageDescription`
|
||||||
|
- `NSBluetoothPeripheralUsageDescription`
|
||||||
|
|
||||||
|
## Coding Conventions
|
||||||
|
|
||||||
|
### Code Philosophy
|
||||||
|
- **Minimal**: Only write code that is necessary. Avoid over-engineering.
|
||||||
|
- **Organized**: Keep related code together. One responsibility per file.
|
||||||
|
- **Maintainable**: Favor readability over cleverness. Simple is better.
|
||||||
|
|
||||||
|
### Style
|
||||||
|
- Use `StatelessWidget` with `Consumer` for state-dependent UI
|
||||||
|
- Use `const` constructors where possible
|
||||||
|
- Prefix private methods/fields with `_`
|
||||||
|
- Center app bar titles (`centerTitle: true`)
|
||||||
|
- **Material widgets only** - no Cupertino or custom widgets
|
||||||
|
- Handle disconnection gracefully (auto-navigate back to scanner)
|
||||||
|
|
||||||
|
### Avoid
|
||||||
|
- Premature abstractions - don't create helpers until needed in 3+ places
|
||||||
|
- Unnecessary comments - code should be self-explanatory
|
||||||
|
- Feature flags or backwards-compatibility shims
|
||||||
|
- Over-engineered error handling for impossible scenarios
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `lib/connector/meshcore_connector.dart` | All BLE logic - scanning, connecting, data transfer |
|
||||||
|
| `lib/screens/scanner_screen.dart` | Entry point UI, device list |
|
||||||
|
| `lib/main.dart` | App configuration, theme, Provider setup |
|
||||||
|
| `pubspec.yaml` | Dependencies and project metadata |
|
||||||
|
|
||||||
|
## Placeholder Screens
|
||||||
|
|
||||||
|
The following screens are implemented as placeholders and need full implementation:
|
||||||
|
- `chat_screen.dart` - Mesh chat functionality
|
||||||
|
- `contacts_screen.dart` - Contact management
|
||||||
|
- `settings_screen.dart` - Radio settings, node identity, location (partially implemented)
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
# meshcore_open
|
||||||
|
|
||||||
|
A new Flutter project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at https://dart.dev/lints.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.meshcore.meshcore_open"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.meshcore.meshcore_open"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Internet permission (required for map tiles) -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- Bluetooth permissions -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
|
||||||
|
<!-- Location permission (required for BLE scanning on Android 11 and below) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
|
<!-- Notification permission (required for Android 13+) -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="meshcore_open"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.meshcore.meshcore_open
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBuildDir: Directory =
|
||||||
|
rootProject.layout.buildDirectory
|
||||||
|
.dir("../../build")
|
||||||
|
.get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
val flutterSdkPath =
|
||||||
|
run {
|
||||||
|
val properties = java.util.Properties()
|
||||||
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
|
flutterSdkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
|
id("com.android.application") version "8.9.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
**/dgph
|
||||||
|
*.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
*.moved-aside
|
||||||
|
*.pbxuser
|
||||||
|
*.perspectivev3
|
||||||
|
**/*sync/
|
||||||
|
.sconsign.dblite
|
||||||
|
.tags*
|
||||||
|
**/.vagrant/
|
||||||
|
**/DerivedData/
|
||||||
|
Icon?
|
||||||
|
**/Pods/
|
||||||
|
**/.symlinks/
|
||||||
|
profile
|
||||||
|
xcuserdata
|
||||||
|
**/.generated/
|
||||||
|
Flutter/App.framework
|
||||||
|
Flutter/Flutter.framework
|
||||||
|
Flutter/Flutter.podspec
|
||||||
|
Flutter/Generated.xcconfig
|
||||||
|
Flutter/ephemeral/
|
||||||
|
Flutter/app.flx
|
||||||
|
Flutter/app.zip
|
||||||
|
Flutter/flutter_assets/
|
||||||
|
Flutter/flutter_export_environment.sh
|
||||||
|
ServiceDefinitions.json
|
||||||
|
Runner/GeneratedPluginRegistrant.*
|
||||||
|
|
||||||
|
# Exceptions to above rules.
|
||||||
|
!default.mode1v3
|
||||||
|
!default.mode2v3
|
||||||
|
!default.pbxuser
|
||||||
|
!default.perspectivev3
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>io.flutter.flutter.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@ -0,0 +1,616 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 54;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||||
|
remoteInfo = Runner;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 10;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
name = "Embed Frameworks";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||||
|
);
|
||||||
|
path = RunnerTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Flutter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146E51CF9000F007C117D = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146EF1CF9000F007C117D /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */,
|
||||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
|
);
|
||||||
|
path = Runner;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = RunnerTests;
|
||||||
|
productName = RunnerTests;
|
||||||
|
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
|
buildPhases = (
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = Runner;
|
||||||
|
productName = Runner;
|
||||||
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1510;
|
||||||
|
ORGANIZATIONNAME = "";
|
||||||
|
TargetAttributes = {
|
||||||
|
331C8080294A63A400263BE5 = {
|
||||||
|
CreatedOnToolsVersion = 14.0;
|
||||||
|
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||||
|
};
|
||||||
|
97C146ED1CF9000F007C117D = {
|
||||||
|
CreatedOnToolsVersion = 7.3.1;
|
||||||
|
LastSwiftMigration = 1100;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||||
|
compatibilityVersion = "Xcode 9.3";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
331C807F294A63A400263BE5 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||||
|
);
|
||||||
|
name = "Thin Binary";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||||
|
};
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Run Script";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
331C807D294A63A400263BE5 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||||
|
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C146FB1CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = Main.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C147001CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = LaunchScreen.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
97C147031CF9000F007C117D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
97C147041CF9000F007C117D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
97C147061CF9000F007C117D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
97C147071CF9000F007C117D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
331C8088294A63A400263BE5 /* Debug */,
|
||||||
|
331C8089294A63A400263BE5 /* Release */,
|
||||||
|
331C808A294A63A400263BE5 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147031CF9000F007C117D /* Debug */,
|
||||||
|
97C147041CF9000F007C117D /* Release */,
|
||||||
|
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147061CF9000F007C117D /* Debug */,
|
||||||
|
97C147071CF9000F007C117D /* Release */,
|
||||||
|
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1510"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||||
|
BuildableName = "RunnerTests.xctest"
|
||||||
|
BlueprintName = "RunnerTests"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Profile"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-20x20@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-40x40@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-60x60@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-60x60@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-20x20@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-40x40@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-76x76@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-76x76@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "83.5x83.5",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "1024x1024",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"filename" : "Icon-App-1024x1024@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@ -0,0 +1,5 @@
|
|||||||
|
# Launch Screen Assets
|
||||||
|
|
||||||
|
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||||
|
|
||||||
|
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
|
</imageView>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Flutter View Controller-->
|
||||||
|
<scene sceneID="tne-QT-ifu">
|
||||||
|
<objects>
|
||||||
|
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Meshcore Open</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>meshcore_open</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||||
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
class RunnerTests: XCTestCase {
|
||||||
|
|
||||||
|
func testExample() {
|
||||||
|
// If you add code to the Runner application, consider adding tests here.
|
||||||
|
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,411 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class Smaz {
|
||||||
|
static const int _verbatimSingle = 254;
|
||||||
|
static const int _verbatimRun = 255;
|
||||||
|
|
||||||
|
static const List<String> _rcb = [
|
||||||
|
" ",
|
||||||
|
"the",
|
||||||
|
"e",
|
||||||
|
"t",
|
||||||
|
"a",
|
||||||
|
"of",
|
||||||
|
"o",
|
||||||
|
"and",
|
||||||
|
"i",
|
||||||
|
"n",
|
||||||
|
"s",
|
||||||
|
"e ",
|
||||||
|
"r",
|
||||||
|
" th",
|
||||||
|
" t",
|
||||||
|
"in",
|
||||||
|
"he",
|
||||||
|
"th",
|
||||||
|
"h",
|
||||||
|
"he ",
|
||||||
|
"to",
|
||||||
|
"\r\n",
|
||||||
|
"l",
|
||||||
|
"s ",
|
||||||
|
"d",
|
||||||
|
" a",
|
||||||
|
"an",
|
||||||
|
"er",
|
||||||
|
"c",
|
||||||
|
" o",
|
||||||
|
"d ",
|
||||||
|
"on",
|
||||||
|
" of",
|
||||||
|
"re",
|
||||||
|
"of ",
|
||||||
|
"t ",
|
||||||
|
", ",
|
||||||
|
"is",
|
||||||
|
"u",
|
||||||
|
"at",
|
||||||
|
" ",
|
||||||
|
"n ",
|
||||||
|
"or",
|
||||||
|
"which",
|
||||||
|
"f",
|
||||||
|
"m",
|
||||||
|
"as",
|
||||||
|
"it",
|
||||||
|
"that",
|
||||||
|
"\n",
|
||||||
|
"was",
|
||||||
|
"en",
|
||||||
|
" ",
|
||||||
|
" w",
|
||||||
|
"es",
|
||||||
|
" an",
|
||||||
|
" i",
|
||||||
|
"\r",
|
||||||
|
"f ",
|
||||||
|
"g",
|
||||||
|
"p",
|
||||||
|
"nd",
|
||||||
|
" s",
|
||||||
|
"nd ",
|
||||||
|
"ed ",
|
||||||
|
"w",
|
||||||
|
"ed",
|
||||||
|
"http://",
|
||||||
|
"for",
|
||||||
|
"te",
|
||||||
|
"ing",
|
||||||
|
"y ",
|
||||||
|
"The",
|
||||||
|
" c",
|
||||||
|
"ti",
|
||||||
|
"r ",
|
||||||
|
"his",
|
||||||
|
"st",
|
||||||
|
" in",
|
||||||
|
"ar",
|
||||||
|
"nt",
|
||||||
|
",",
|
||||||
|
" to",
|
||||||
|
"y",
|
||||||
|
"ng",
|
||||||
|
" h",
|
||||||
|
"with",
|
||||||
|
"le",
|
||||||
|
"al",
|
||||||
|
"to ",
|
||||||
|
"b",
|
||||||
|
"ou",
|
||||||
|
"be",
|
||||||
|
"were",
|
||||||
|
" b",
|
||||||
|
"se",
|
||||||
|
"o ",
|
||||||
|
"ent",
|
||||||
|
"ha",
|
||||||
|
"ng ",
|
||||||
|
"their",
|
||||||
|
"\"",
|
||||||
|
"hi",
|
||||||
|
"from",
|
||||||
|
" f",
|
||||||
|
"in ",
|
||||||
|
"de",
|
||||||
|
"ion",
|
||||||
|
"me",
|
||||||
|
"v",
|
||||||
|
".",
|
||||||
|
"ve",
|
||||||
|
"all",
|
||||||
|
"re ",
|
||||||
|
"ri",
|
||||||
|
"ro",
|
||||||
|
"is ",
|
||||||
|
"co",
|
||||||
|
"f t",
|
||||||
|
"are",
|
||||||
|
"ea",
|
||||||
|
". ",
|
||||||
|
"her",
|
||||||
|
" m",
|
||||||
|
"er ",
|
||||||
|
" p",
|
||||||
|
"es ",
|
||||||
|
"by",
|
||||||
|
"they",
|
||||||
|
"di",
|
||||||
|
"ra",
|
||||||
|
"ic",
|
||||||
|
"not",
|
||||||
|
"s, ",
|
||||||
|
"d t",
|
||||||
|
"at ",
|
||||||
|
"ce",
|
||||||
|
"la",
|
||||||
|
"h ",
|
||||||
|
"ne",
|
||||||
|
"as ",
|
||||||
|
"tio",
|
||||||
|
"on ",
|
||||||
|
"n t",
|
||||||
|
"io",
|
||||||
|
"we",
|
||||||
|
" a ",
|
||||||
|
"om",
|
||||||
|
", a",
|
||||||
|
"s o",
|
||||||
|
"ur",
|
||||||
|
"li",
|
||||||
|
"ll",
|
||||||
|
"ch",
|
||||||
|
"had",
|
||||||
|
"this",
|
||||||
|
"e t",
|
||||||
|
"g ",
|
||||||
|
"e\r\n",
|
||||||
|
" wh",
|
||||||
|
"ere",
|
||||||
|
" co",
|
||||||
|
"e o",
|
||||||
|
"a ",
|
||||||
|
"us",
|
||||||
|
" d",
|
||||||
|
"ss",
|
||||||
|
"\n\r\n",
|
||||||
|
"\r\n\r",
|
||||||
|
"=\"",
|
||||||
|
" be",
|
||||||
|
" e",
|
||||||
|
"s a",
|
||||||
|
"ma",
|
||||||
|
"one",
|
||||||
|
"t t",
|
||||||
|
"or ",
|
||||||
|
"but",
|
||||||
|
"el",
|
||||||
|
"so",
|
||||||
|
"l ",
|
||||||
|
"e s",
|
||||||
|
"s,",
|
||||||
|
"no",
|
||||||
|
"ter",
|
||||||
|
" wa",
|
||||||
|
"iv",
|
||||||
|
"ho",
|
||||||
|
"e a",
|
||||||
|
" r",
|
||||||
|
"hat",
|
||||||
|
"s t",
|
||||||
|
"ns",
|
||||||
|
"ch ",
|
||||||
|
"wh",
|
||||||
|
"tr",
|
||||||
|
"ut",
|
||||||
|
"/",
|
||||||
|
"have",
|
||||||
|
"ly ",
|
||||||
|
"ta",
|
||||||
|
" ha",
|
||||||
|
" on",
|
||||||
|
"tha",
|
||||||
|
"-",
|
||||||
|
" l",
|
||||||
|
"ati",
|
||||||
|
"en ",
|
||||||
|
"pe",
|
||||||
|
" re",
|
||||||
|
"there",
|
||||||
|
"ass",
|
||||||
|
"si",
|
||||||
|
" fo",
|
||||||
|
"wa",
|
||||||
|
"ec",
|
||||||
|
"our",
|
||||||
|
"who",
|
||||||
|
"its",
|
||||||
|
"z",
|
||||||
|
"fo",
|
||||||
|
"rs",
|
||||||
|
">",
|
||||||
|
"ot",
|
||||||
|
"un",
|
||||||
|
"<",
|
||||||
|
"im",
|
||||||
|
"th ",
|
||||||
|
"nc",
|
||||||
|
"ate",
|
||||||
|
"><",
|
||||||
|
"ver",
|
||||||
|
"ad",
|
||||||
|
" we",
|
||||||
|
"ly",
|
||||||
|
"ee",
|
||||||
|
" n",
|
||||||
|
"id",
|
||||||
|
" cl",
|
||||||
|
"ac",
|
||||||
|
"il",
|
||||||
|
"</",
|
||||||
|
"rt",
|
||||||
|
" wi",
|
||||||
|
"div",
|
||||||
|
"e, ",
|
||||||
|
" it",
|
||||||
|
"whi",
|
||||||
|
" ma",
|
||||||
|
"ge",
|
||||||
|
"x",
|
||||||
|
"e c",
|
||||||
|
"men",
|
||||||
|
".com",
|
||||||
|
];
|
||||||
|
|
||||||
|
static final List<Uint8List> _rcbBytes =
|
||||||
|
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false);
|
||||||
|
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
|
||||||
|
return entry.length > maxLen ? entry.length : maxLen;
|
||||||
|
});
|
||||||
|
|
||||||
|
static String encodeIfSmaller(String text) {
|
||||||
|
if (text.isEmpty || text.startsWith('s:')) return text;
|
||||||
|
final originalBytes = Uint8List.fromList(utf8.encode(text));
|
||||||
|
final compressed = compressBytes(originalBytes);
|
||||||
|
final encoded = base64Encode(compressed);
|
||||||
|
final candidate = 's:$encoded';
|
||||||
|
if (utf8.encode(candidate).length < originalBytes.length) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? tryDecodePrefixed(String text) {
|
||||||
|
final trimmedLeft = text.trimLeft();
|
||||||
|
if (!trimmedLeft.startsWith('s:') || trimmedLeft.length <= 2) return null;
|
||||||
|
final encoded = trimmedLeft.substring(2);
|
||||||
|
try {
|
||||||
|
final compressed = _decodeBase64Flexible(encoded);
|
||||||
|
final decompressed = decompressBytes(compressed);
|
||||||
|
return utf8.decode(decompressed, allowMalformed: true);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List compressBytes(Uint8List input) {
|
||||||
|
final out = BytesBuilder(copy: false);
|
||||||
|
final verbatim = <int>[];
|
||||||
|
int index = 0;
|
||||||
|
|
||||||
|
void flushVerbatim() {
|
||||||
|
if (verbatim.isEmpty) return;
|
||||||
|
if (verbatim.length == 1) {
|
||||||
|
out.addByte(_verbatimSingle);
|
||||||
|
out.addByte(verbatim[0]);
|
||||||
|
} else {
|
||||||
|
out.addByte(_verbatimRun);
|
||||||
|
out.addByte(verbatim.length - 1);
|
||||||
|
out.add(verbatim);
|
||||||
|
}
|
||||||
|
verbatim.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
while (index < input.length) {
|
||||||
|
int bestLen = 0;
|
||||||
|
int bestCode = -1;
|
||||||
|
final remaining = input.length - index;
|
||||||
|
final maxLen = remaining < _maxEntryLen ? remaining : _maxEntryLen;
|
||||||
|
|
||||||
|
for (int code = 0; code < _rcbBytes.length; code++) {
|
||||||
|
final entry = _rcbBytes[code];
|
||||||
|
final entryLen = entry.length;
|
||||||
|
if (entryLen == 0 || entryLen > maxLen || entryLen <= bestLen) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (_matches(input, index, entry)) {
|
||||||
|
bestLen = entryLen;
|
||||||
|
bestCode = code;
|
||||||
|
if (bestLen == maxLen) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestCode >= 0) {
|
||||||
|
flushVerbatim();
|
||||||
|
out.addByte(bestCode);
|
||||||
|
index += bestLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
verbatim.add(input[index]);
|
||||||
|
index++;
|
||||||
|
if (verbatim.length == 256) {
|
||||||
|
flushVerbatim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushVerbatim();
|
||||||
|
return out.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List decompressBytes(Uint8List input) {
|
||||||
|
final out = BytesBuilder(copy: false);
|
||||||
|
int index = 0;
|
||||||
|
|
||||||
|
while (index < input.length) {
|
||||||
|
final code = input[index];
|
||||||
|
if (code == _verbatimSingle) {
|
||||||
|
if (index + 1 >= input.length) {
|
||||||
|
throw const FormatException('Invalid SMAZ stream: truncated verbatim byte.');
|
||||||
|
}
|
||||||
|
out.addByte(input[index + 1]);
|
||||||
|
index += 2;
|
||||||
|
} else if (code == _verbatimRun) {
|
||||||
|
if (index + 1 >= input.length) {
|
||||||
|
throw const FormatException('Invalid SMAZ stream: truncated verbatim length.');
|
||||||
|
}
|
||||||
|
final len = input[index + 1] + 1;
|
||||||
|
final end = index + 2 + len;
|
||||||
|
if (end > input.length) {
|
||||||
|
throw const FormatException('Invalid SMAZ stream: truncated verbatim run.');
|
||||||
|
}
|
||||||
|
out.add(input.sublist(index + 2, end));
|
||||||
|
index = end;
|
||||||
|
} else {
|
||||||
|
if (code >= _rcbBytes.length) {
|
||||||
|
throw const FormatException('Invalid SMAZ stream: code out of range.');
|
||||||
|
}
|
||||||
|
out.add(_rcbBytes[code]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _matches(Uint8List input, int offset, Uint8List entry) {
|
||||||
|
final len = entry.length;
|
||||||
|
if (offset + len > input.length) return false;
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
if (input[offset + i] != entry[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List _decodeBase64Flexible(String encoded) {
|
||||||
|
final trimmed = encoded.trim();
|
||||||
|
try {
|
||||||
|
return base64Decode(trimmed);
|
||||||
|
} catch (_) {
|
||||||
|
// Try base64url with missing padding.
|
||||||
|
var normalized = trimmed.replaceAll('-', '+').replaceAll('_', '/');
|
||||||
|
final pad = normalized.length % 4;
|
||||||
|
if (pad != 0) {
|
||||||
|
normalized = normalized.padRight(normalized.length + (4 - pad), '=');
|
||||||
|
}
|
||||||
|
return base64Decode(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'connector/meshcore_connector.dart';
|
||||||
|
import 'screens/scanner_screen.dart';
|
||||||
|
import 'services/storage_service.dart';
|
||||||
|
import 'services/message_retry_service.dart';
|
||||||
|
import 'services/path_history_service.dart';
|
||||||
|
import 'services/app_settings_service.dart';
|
||||||
|
import 'services/notification_service.dart';
|
||||||
|
import 'services/ble_debug_log_service.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
final storage = StorageService();
|
||||||
|
final connector = MeshCoreConnector();
|
||||||
|
final pathHistoryService = PathHistoryService(storage);
|
||||||
|
final retryService = MessageRetryService(storage);
|
||||||
|
final appSettingsService = AppSettingsService();
|
||||||
|
final bleDebugLogService = BleDebugLogService();
|
||||||
|
|
||||||
|
// Load settings
|
||||||
|
await appSettingsService.loadSettings();
|
||||||
|
|
||||||
|
// Initialize notification service
|
||||||
|
final notificationService = NotificationService();
|
||||||
|
await notificationService.initialize();
|
||||||
|
|
||||||
|
// Wire up connector with services
|
||||||
|
connector.initialize(
|
||||||
|
retryService: retryService,
|
||||||
|
pathHistoryService: pathHistoryService,
|
||||||
|
appSettingsService: appSettingsService,
|
||||||
|
bleDebugLogService: bleDebugLogService,
|
||||||
|
);
|
||||||
|
|
||||||
|
await connector.loadContactCache();
|
||||||
|
await connector.loadChannelSettings();
|
||||||
|
|
||||||
|
// Load persisted channel messages
|
||||||
|
await connector.loadAllChannelMessages();
|
||||||
|
|
||||||
|
runApp(MeshCoreApp(
|
||||||
|
connector: connector,
|
||||||
|
retryService: retryService,
|
||||||
|
pathHistoryService: pathHistoryService,
|
||||||
|
storage: storage,
|
||||||
|
appSettingsService: appSettingsService,
|
||||||
|
bleDebugLogService: bleDebugLogService,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
class MeshCoreApp extends StatelessWidget {
|
||||||
|
final MeshCoreConnector connector;
|
||||||
|
final MessageRetryService retryService;
|
||||||
|
final PathHistoryService pathHistoryService;
|
||||||
|
final StorageService storage;
|
||||||
|
final AppSettingsService appSettingsService;
|
||||||
|
final BleDebugLogService bleDebugLogService;
|
||||||
|
|
||||||
|
const MeshCoreApp({
|
||||||
|
super.key,
|
||||||
|
required this.connector,
|
||||||
|
required this.retryService,
|
||||||
|
required this.pathHistoryService,
|
||||||
|
required this.storage,
|
||||||
|
required this.appSettingsService,
|
||||||
|
required this.bleDebugLogService,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: connector),
|
||||||
|
ChangeNotifierProvider.value(value: retryService),
|
||||||
|
ChangeNotifierProvider.value(value: pathHistoryService),
|
||||||
|
ChangeNotifierProvider.value(value: appSettingsService),
|
||||||
|
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||||
|
Provider.value(value: storage),
|
||||||
|
],
|
||||||
|
child: Consumer<AppSettingsService>(
|
||||||
|
builder: (context, settingsService, child) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'MeshCore Open',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||||
|
useMaterial3: true,
|
||||||
|
),
|
||||||
|
darkTheme: ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.blue,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
useMaterial3: true,
|
||||||
|
),
|
||||||
|
themeMode: _themeModeFromSetting(settingsService.settings.themeMode),
|
||||||
|
home: const ScannerScreen(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeMode _themeModeFromSetting(String value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'light':
|
||||||
|
return ThemeMode.light;
|
||||||
|
case 'dark':
|
||||||
|
return ThemeMode.dark;
|
||||||
|
default:
|
||||||
|
return ThemeMode.system;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
class AppSettings {
|
||||||
|
final bool clearPathOnMaxRetry;
|
||||||
|
final bool mapShowRepeaters;
|
||||||
|
final bool mapShowChatNodes;
|
||||||
|
final bool mapShowOtherNodes;
|
||||||
|
final double mapTimeFilterHours; // 0 = all time
|
||||||
|
final bool mapKeyPrefixEnabled;
|
||||||
|
final String mapKeyPrefix;
|
||||||
|
final bool mapShowMarkers;
|
||||||
|
final bool notificationsEnabled;
|
||||||
|
final bool notifyOnNewMessage;
|
||||||
|
final bool notifyOnNewAdvert;
|
||||||
|
final bool autoRouteRotationEnabled;
|
||||||
|
final String themeMode;
|
||||||
|
final Map<String, String> batteryChemistryByDeviceId;
|
||||||
|
|
||||||
|
AppSettings({
|
||||||
|
this.clearPathOnMaxRetry = false,
|
||||||
|
this.mapShowRepeaters = true,
|
||||||
|
this.mapShowChatNodes = true,
|
||||||
|
this.mapShowOtherNodes = true,
|
||||||
|
this.mapTimeFilterHours = 0, // Default to all time
|
||||||
|
this.mapKeyPrefixEnabled = false,
|
||||||
|
this.mapKeyPrefix = '',
|
||||||
|
this.mapShowMarkers = true,
|
||||||
|
this.notificationsEnabled = true,
|
||||||
|
this.notifyOnNewMessage = true,
|
||||||
|
this.notifyOnNewAdvert = true,
|
||||||
|
this.autoRouteRotationEnabled = false,
|
||||||
|
this.themeMode = 'system',
|
||||||
|
Map<String, String>? batteryChemistryByDeviceId,
|
||||||
|
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'clear_path_on_max_retry': clearPathOnMaxRetry,
|
||||||
|
'map_show_repeaters': mapShowRepeaters,
|
||||||
|
'map_show_chat_nodes': mapShowChatNodes,
|
||||||
|
'map_show_other_nodes': mapShowOtherNodes,
|
||||||
|
'map_time_filter_hours': mapTimeFilterHours,
|
||||||
|
'map_key_prefix_enabled': mapKeyPrefixEnabled,
|
||||||
|
'map_key_prefix': mapKeyPrefix,
|
||||||
|
'map_show_markers': mapShowMarkers,
|
||||||
|
'notifications_enabled': notificationsEnabled,
|
||||||
|
'notify_on_new_message': notifyOnNewMessage,
|
||||||
|
'notify_on_new_advert': notifyOnNewAdvert,
|
||||||
|
'auto_route_rotation_enabled': autoRouteRotationEnabled,
|
||||||
|
'theme_mode': themeMode,
|
||||||
|
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AppSettings.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AppSettings(
|
||||||
|
clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false,
|
||||||
|
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
|
||||||
|
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
|
||||||
|
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
|
||||||
|
mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||||
|
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
|
||||||
|
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
|
||||||
|
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
|
||||||
|
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
|
||||||
|
notifyOnNewMessage: json['notify_on_new_message'] as bool? ?? true,
|
||||||
|
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||||
|
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||||
|
themeMode: json['theme_mode'] as String? ?? 'system',
|
||||||
|
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
|
||||||
|
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||||
|
) ??
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings copyWith({
|
||||||
|
bool? clearPathOnMaxRetry,
|
||||||
|
bool? mapShowRepeaters,
|
||||||
|
bool? mapShowChatNodes,
|
||||||
|
bool? mapShowOtherNodes,
|
||||||
|
double? mapTimeFilterHours,
|
||||||
|
bool? mapKeyPrefixEnabled,
|
||||||
|
String? mapKeyPrefix,
|
||||||
|
bool? mapShowMarkers,
|
||||||
|
bool? notificationsEnabled,
|
||||||
|
bool? notifyOnNewMessage,
|
||||||
|
bool? notifyOnNewAdvert,
|
||||||
|
bool? autoRouteRotationEnabled,
|
||||||
|
String? themeMode,
|
||||||
|
Map<String, String>? batteryChemistryByDeviceId,
|
||||||
|
}) {
|
||||||
|
return AppSettings(
|
||||||
|
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||||
|
mapShowRepeaters: mapShowRepeaters ?? this.mapShowRepeaters,
|
||||||
|
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
|
||||||
|
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
|
||||||
|
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
|
||||||
|
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
|
||||||
|
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
|
||||||
|
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
|
||||||
|
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||||
|
notifyOnNewMessage: notifyOnNewMessage ?? this.notifyOnNewMessage,
|
||||||
|
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||||
|
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||||
|
themeMode: themeMode ?? this.themeMode,
|
||||||
|
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
|
class Channel {
|
||||||
|
final int index;
|
||||||
|
final String name;
|
||||||
|
final Uint8List psk; // 16 bytes
|
||||||
|
|
||||||
|
Channel({
|
||||||
|
required this.index,
|
||||||
|
required this.name,
|
||||||
|
required this.psk,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get pskBase64 => base64Encode(psk);
|
||||||
|
|
||||||
|
bool get isEmpty => name.isEmpty && psk.every((b) => b == 0);
|
||||||
|
|
||||||
|
bool get isPublicChannel => pskBase64 == publicChannelPsk;
|
||||||
|
|
||||||
|
static Channel? fromFrame(Uint8List data) {
|
||||||
|
// CHANNEL_INFO format:
|
||||||
|
// [0] = RESP_CODE_CHANNEL_INFO (18)
|
||||||
|
// [1] = channel_idx
|
||||||
|
// [2-33] = name (32 bytes, null-terminated)
|
||||||
|
// [34-49] = psk (16 bytes)
|
||||||
|
if (data.length < 50) return null;
|
||||||
|
if (data[0] != respCodeChannelInfo) return null;
|
||||||
|
|
||||||
|
final index = data[1];
|
||||||
|
final name = readCString(data, 2, 32);
|
||||||
|
final psk = Uint8List.fromList(data.sublist(34, 50));
|
||||||
|
|
||||||
|
return Channel(index: index, name: name, psk: psk);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Channel empty(int index) {
|
||||||
|
return Channel(
|
||||||
|
index: index,
|
||||||
|
name: '',
|
||||||
|
psk: Uint8List(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Channel fromPsk(int index, String name, String pskBase64) {
|
||||||
|
final pskBytes = base64Decode(pskBase64);
|
||||||
|
final psk = Uint8List(16);
|
||||||
|
for (int i = 0; i < pskBytes.length && i < 16; i++) {
|
||||||
|
psk[i] = pskBytes[i];
|
||||||
|
}
|
||||||
|
return Channel(index: index, name: name, psk: psk);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const String publicChannelPsk = 'izOH6cXN6mrJ5e26oRXNcg==';
|
||||||
|
}
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../helpers/smaz.dart';
|
||||||
|
|
||||||
|
enum ChannelMessageStatus { pending, sent, failed }
|
||||||
|
|
||||||
|
class Repeat {
|
||||||
|
final Uint8List? repeaterKey;
|
||||||
|
final String repeaterName;
|
||||||
|
final int tripTimeMs;
|
||||||
|
final List<Uint8List>? path;
|
||||||
|
|
||||||
|
Repeat({
|
||||||
|
this.repeaterKey,
|
||||||
|
required this.repeaterName,
|
||||||
|
required this.tripTimeMs,
|
||||||
|
this.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
String? get repeaterKeyHex =>
|
||||||
|
repeaterKey != null ? pubKeyToHex(repeaterKey!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChannelMessage {
|
||||||
|
final Uint8List? senderKey;
|
||||||
|
final String senderName;
|
||||||
|
final String text;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final bool isOutgoing;
|
||||||
|
final ChannelMessageStatus status;
|
||||||
|
final List<Repeat> repeats;
|
||||||
|
final int repeatCount;
|
||||||
|
final int? pathLength;
|
||||||
|
final Uint8List pathBytes;
|
||||||
|
final int? channelIndex;
|
||||||
|
|
||||||
|
ChannelMessage({
|
||||||
|
this.senderKey,
|
||||||
|
required this.senderName,
|
||||||
|
required this.text,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.isOutgoing,
|
||||||
|
this.status = ChannelMessageStatus.pending,
|
||||||
|
this.repeats = const [],
|
||||||
|
this.repeatCount = 0,
|
||||||
|
this.pathLength,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
this.channelIndex,
|
||||||
|
}) : pathBytes = pathBytes ?? Uint8List(0);
|
||||||
|
|
||||||
|
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
|
||||||
|
|
||||||
|
ChannelMessage copyWith({
|
||||||
|
ChannelMessageStatus? status,
|
||||||
|
List<Repeat>? repeats,
|
||||||
|
int? repeatCount,
|
||||||
|
int? pathLength,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
}) {
|
||||||
|
return ChannelMessage(
|
||||||
|
senderKey: senderKey,
|
||||||
|
senderName: senderName,
|
||||||
|
text: text,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isOutgoing: isOutgoing,
|
||||||
|
status: status ?? this.status,
|
||||||
|
repeats: repeats ?? this.repeats,
|
||||||
|
repeatCount: repeatCount ?? this.repeatCount,
|
||||||
|
pathLength: pathLength ?? this.pathLength,
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
channelIndex: channelIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ChannelMessage? fromFrame(Uint8List data) {
|
||||||
|
// CHANNEL_MSG_RECV format varies by version:
|
||||||
|
// V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...]
|
||||||
|
// Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text
|
||||||
|
if (data.length < 8) return null;
|
||||||
|
|
||||||
|
final code = data[0];
|
||||||
|
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int timestampOffset, textOffset, pathLenOffset, txtTypeOffset;
|
||||||
|
Uint8List pathBytes = Uint8List(0);
|
||||||
|
int channelIdx;
|
||||||
|
|
||||||
|
if (code == respCodeChannelMsgRecvV3) {
|
||||||
|
channelIdx = data[4];
|
||||||
|
pathLenOffset = 5;
|
||||||
|
final pathLen = data[pathLenOffset].toSigned(8);
|
||||||
|
var cursor = 6;
|
||||||
|
final hasPathBytesFlag = (data[2] & 0x01) != 0;
|
||||||
|
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
|
||||||
|
final hasValidTxtType =
|
||||||
|
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
|
||||||
|
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) {
|
||||||
|
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
|
||||||
|
cursor += pathLen;
|
||||||
|
}
|
||||||
|
txtTypeOffset = cursor;
|
||||||
|
cursor += 1; // txt_type
|
||||||
|
timestampOffset = cursor;
|
||||||
|
textOffset = cursor + 4;
|
||||||
|
} else {
|
||||||
|
channelIdx = data[1];
|
||||||
|
pathLenOffset = 2;
|
||||||
|
txtTypeOffset = 3;
|
||||||
|
timestampOffset = 4;
|
||||||
|
textOffset = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length < textOffset + 1) return null;
|
||||||
|
|
||||||
|
final txtType = data[txtTypeOffset];
|
||||||
|
if (txtType != txtTypePlain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pathLen = data[pathLenOffset].toSigned(8);
|
||||||
|
final timestampRaw = readUint32LE(data, timestampOffset);
|
||||||
|
final text = readCString(data, textOffset, data.length - textOffset);
|
||||||
|
|
||||||
|
// Extract sender name and actual message from "name: msg" format
|
||||||
|
String senderName = 'Unknown';
|
||||||
|
String actualText = text;
|
||||||
|
|
||||||
|
final colonIndex = text.indexOf(':');
|
||||||
|
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
|
||||||
|
final potentialSender = text.substring(0, colonIndex);
|
||||||
|
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
|
||||||
|
senderName = potentialSender;
|
||||||
|
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
||||||
|
? colonIndex + 2
|
||||||
|
: colonIndex + 1;
|
||||||
|
actualText = text.substring(offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
|
||||||
|
|
||||||
|
return ChannelMessage(
|
||||||
|
senderKey: null,
|
||||||
|
senderName: senderName,
|
||||||
|
text: decodedText,
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
|
||||||
|
isOutgoing: false,
|
||||||
|
status: ChannelMessageStatus.sent,
|
||||||
|
pathLength: pathLen,
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
channelIndex: channelIdx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ChannelMessage outgoing(String text, String senderName, int channelIndex) {
|
||||||
|
return ChannelMessage(
|
||||||
|
senderKey: null,
|
||||||
|
senderName: senderName,
|
||||||
|
text: text,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
isOutgoing: true,
|
||||||
|
status: ChannelMessageStatus.pending,
|
||||||
|
pathLength: null,
|
||||||
|
pathBytes: Uint8List(0),
|
||||||
|
channelIndex: channelIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
|
class Contact {
|
||||||
|
final Uint8List publicKey;
|
||||||
|
final String name;
|
||||||
|
final int type;
|
||||||
|
final int pathLength; // -1 = flood, 0+ = direct hops
|
||||||
|
final Uint8List path;
|
||||||
|
final double? latitude;
|
||||||
|
final double? longitude;
|
||||||
|
final DateTime lastSeen;
|
||||||
|
|
||||||
|
Contact({
|
||||||
|
required this.publicKey,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
required this.pathLength,
|
||||||
|
required this.path,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
|
required this.lastSeen,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get publicKeyHex => pubKeyToHex(publicKey);
|
||||||
|
|
||||||
|
String get typeLabel {
|
||||||
|
switch (type) {
|
||||||
|
case advTypeChat:
|
||||||
|
return 'Chat';
|
||||||
|
case advTypeRepeater:
|
||||||
|
return 'Repeater';
|
||||||
|
case advTypeRoom:
|
||||||
|
return 'Room';
|
||||||
|
case advTypeSensor:
|
||||||
|
return 'Sensor';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get pathLabel {
|
||||||
|
if (pathLength < 0) return 'Flood';
|
||||||
|
if (pathLength == 0) return 'Direct';
|
||||||
|
return '$pathLength hops';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasLocation => latitude != null && longitude != null;
|
||||||
|
|
||||||
|
String get pathIdList {
|
||||||
|
if (path.isEmpty) return '';
|
||||||
|
final parts = <String>[];
|
||||||
|
final groupSize = pathHashSize;
|
||||||
|
for (int i = 0; i < path.length; i += groupSize) {
|
||||||
|
final end = (i + groupSize) <= path.length ? (i + groupSize) : path.length;
|
||||||
|
final chunk = path.sublist(i, end);
|
||||||
|
parts.add(
|
||||||
|
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parts.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Contact? fromFrame(Uint8List data) {
|
||||||
|
if (data.length < contactFrameSize) return null;
|
||||||
|
if (data[0] != respCodeContact) return null;
|
||||||
|
|
||||||
|
final pubKey = Uint8List.fromList(
|
||||||
|
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
|
||||||
|
);
|
||||||
|
final type = data[contactTypeOffset];
|
||||||
|
final pathLen = data[contactPathLenOffset].toSigned(8);
|
||||||
|
final safePathLen = pathLen > 0
|
||||||
|
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
||||||
|
: 0;
|
||||||
|
final pathBytes = safePathLen > 0
|
||||||
|
? Uint8List.fromList(
|
||||||
|
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
|
||||||
|
)
|
||||||
|
: Uint8List(0);
|
||||||
|
final name = readCString(data, contactNameOffset, maxNameSize);
|
||||||
|
final lastmod = readUint32LE(data, contactLastmodOffset);
|
||||||
|
|
||||||
|
double? lat, lon;
|
||||||
|
final latRaw = readInt32LE(data, contactLatOffset);
|
||||||
|
final lonRaw = readInt32LE(data, contactLonOffset);
|
||||||
|
if (latRaw != 0 || lonRaw != 0) {
|
||||||
|
lat = latRaw / 1e6;
|
||||||
|
lon = lonRaw / 1e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: pubKey,
|
||||||
|
name: name.isEmpty ? 'Unknown' : name,
|
||||||
|
type: type,
|
||||||
|
pathLength: pathLen,
|
||||||
|
path: pathBytes,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is Contact && publicKeyHex == other.publicKeyHex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => publicKeyHex.hashCode;
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
class ContactGroup {
|
||||||
|
final String name;
|
||||||
|
final List<String> memberKeys;
|
||||||
|
|
||||||
|
const ContactGroup({
|
||||||
|
required this.name,
|
||||||
|
required this.memberKeys,
|
||||||
|
});
|
||||||
|
|
||||||
|
ContactGroup copyWith({
|
||||||
|
String? name,
|
||||||
|
List<String>? memberKeys,
|
||||||
|
}) {
|
||||||
|
return ContactGroup(
|
||||||
|
name: name ?? this.name,
|
||||||
|
memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'members': memberKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ContactGroup.fromJson(Map<String, dynamic> json) {
|
||||||
|
final members = (json['members'] as List?)
|
||||||
|
?.map((value) => value.toString())
|
||||||
|
.toList() ??
|
||||||
|
<String>[];
|
||||||
|
return ContactGroup(
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
memberKeys: members,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
|
enum MessageStatus { pending, sent, delivered, failed }
|
||||||
|
|
||||||
|
class Message {
|
||||||
|
final Uint8List senderKey;
|
||||||
|
final String text;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final bool isOutgoing;
|
||||||
|
final bool isCli;
|
||||||
|
final MessageStatus status;
|
||||||
|
|
||||||
|
// NEW: Retry logic fields
|
||||||
|
final String? messageId;
|
||||||
|
final int retryCount;
|
||||||
|
final int? estimatedTimeoutMs;
|
||||||
|
final Uint8List? expectedAckHash;
|
||||||
|
final DateTime? sentAt;
|
||||||
|
final DateTime? deliveredAt;
|
||||||
|
final int? tripTimeMs;
|
||||||
|
final bool forceFlood;
|
||||||
|
final int? pathLength;
|
||||||
|
final Uint8List pathBytes;
|
||||||
|
|
||||||
|
Message({
|
||||||
|
required this.senderKey,
|
||||||
|
required this.text,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.isOutgoing,
|
||||||
|
this.isCli = false,
|
||||||
|
this.status = MessageStatus.pending,
|
||||||
|
this.messageId,
|
||||||
|
this.retryCount = 0,
|
||||||
|
this.estimatedTimeoutMs,
|
||||||
|
this.expectedAckHash,
|
||||||
|
this.sentAt,
|
||||||
|
this.deliveredAt,
|
||||||
|
this.tripTimeMs,
|
||||||
|
this.forceFlood = false,
|
||||||
|
this.pathLength,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
}) : pathBytes = pathBytes ?? Uint8List(0);
|
||||||
|
|
||||||
|
String get senderKeyHex => pubKeyToHex(senderKey);
|
||||||
|
|
||||||
|
Message copyWith({
|
||||||
|
MessageStatus? status,
|
||||||
|
int? retryCount,
|
||||||
|
int? estimatedTimeoutMs,
|
||||||
|
Uint8List? expectedAckHash,
|
||||||
|
DateTime? sentAt,
|
||||||
|
DateTime? deliveredAt,
|
||||||
|
int? tripTimeMs,
|
||||||
|
int? pathLength,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
bool? isCli,
|
||||||
|
}) {
|
||||||
|
return Message(
|
||||||
|
senderKey: senderKey,
|
||||||
|
text: text,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isOutgoing: isOutgoing,
|
||||||
|
isCli: isCli ?? this.isCli,
|
||||||
|
status: status ?? this.status,
|
||||||
|
messageId: messageId,
|
||||||
|
retryCount: retryCount ?? this.retryCount,
|
||||||
|
estimatedTimeoutMs: estimatedTimeoutMs ?? this.estimatedTimeoutMs,
|
||||||
|
expectedAckHash: expectedAckHash ?? this.expectedAckHash,
|
||||||
|
sentAt: sentAt ?? this.sentAt,
|
||||||
|
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||||
|
tripTimeMs: tripTimeMs ?? this.tripTimeMs,
|
||||||
|
forceFlood: forceFlood,
|
||||||
|
pathLength: pathLength ?? this.pathLength,
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) {
|
||||||
|
if (data.length < msgTextOffset + 1) return null;
|
||||||
|
|
||||||
|
final code = data[0];
|
||||||
|
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final senderKey = Uint8List.fromList(
|
||||||
|
data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize),
|
||||||
|
);
|
||||||
|
final timestampRaw = readUint32LE(data, msgTimestampOffset);
|
||||||
|
final flags = data[msgFlagsOffset];
|
||||||
|
if ((flags >> 2) != txtTypePlain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final text = readCString(data, msgTextOffset, data.length - msgTextOffset);
|
||||||
|
|
||||||
|
return Message(
|
||||||
|
senderKey: senderKey,
|
||||||
|
text: text,
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
|
||||||
|
isOutgoing: false,
|
||||||
|
isCli: false,
|
||||||
|
status: MessageStatus.delivered,
|
||||||
|
pathBytes: Uint8List(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Message outgoing(
|
||||||
|
Uint8List recipientKey,
|
||||||
|
String text, {
|
||||||
|
int? pathLength,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
}) {
|
||||||
|
return Message(
|
||||||
|
senderKey: recipientKey,
|
||||||
|
text: text,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
isOutgoing: true,
|
||||||
|
isCli: false,
|
||||||
|
status: MessageStatus.pending,
|
||||||
|
pathLength: pathLength,
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
class PathRecord {
|
||||||
|
final int hopCount;
|
||||||
|
final int tripTimeMs;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final bool wasFloodDiscovery;
|
||||||
|
final List<int> pathBytes;
|
||||||
|
final int successCount;
|
||||||
|
final int failureCount;
|
||||||
|
|
||||||
|
PathRecord({
|
||||||
|
required this.hopCount,
|
||||||
|
required this.tripTimeMs,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.wasFloodDiscovery,
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.successCount,
|
||||||
|
required this.failureCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get displayText =>
|
||||||
|
'$hopCount ${hopCount == 1 ? 'hop' : 'hops'} - ${(tripTimeMs / 1000).toStringAsFixed(2)}s';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'hop_count': hopCount,
|
||||||
|
'trip_time_ms': tripTimeMs,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
'was_flood': wasFloodDiscovery,
|
||||||
|
'path_bytes': pathBytes,
|
||||||
|
'success_count': successCount,
|
||||||
|
'failure_count': failureCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PathRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PathRecord(
|
||||||
|
hopCount: json['hop_count'] as int,
|
||||||
|
tripTimeMs: json['trip_time_ms'] as int,
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
wasFloodDiscovery: json['was_flood'] as bool,
|
||||||
|
pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||||
|
successCount: json['success_count'] as int? ?? 0,
|
||||||
|
failureCount: json['failure_count'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactPathHistory {
|
||||||
|
final String contactPubKeyHex;
|
||||||
|
final List<PathRecord> recentPaths;
|
||||||
|
|
||||||
|
ContactPathHistory({
|
||||||
|
required this.contactPubKeyHex,
|
||||||
|
required this.recentPaths,
|
||||||
|
});
|
||||||
|
|
||||||
|
PathRecord? get fastest {
|
||||||
|
if (recentPaths.isEmpty) return null;
|
||||||
|
return recentPaths.reduce((a, b) => a.tripTimeMs < b.tripTimeMs ? a : b);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathRecord? get mostRecent {
|
||||||
|
if (recentPaths.isEmpty) return null;
|
||||||
|
return recentPaths.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ContactPathHistory.fromJson(
|
||||||
|
String contactPubKeyHex, Map<String, dynamic> json) {
|
||||||
|
final pathsList = (json['recent_paths'] as List?)
|
||||||
|
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
return ContactPathHistory(
|
||||||
|
contactPubKeyHex: contactPubKeyHex,
|
||||||
|
recentPaths: pathsList,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
class PathSelection {
|
||||||
|
final List<int> pathBytes;
|
||||||
|
final int hopCount;
|
||||||
|
final bool useFlood;
|
||||||
|
|
||||||
|
const PathSelection({
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.hopCount,
|
||||||
|
required this.useFlood,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
enum LoRaBandwidth {
|
||||||
|
bw7_8(7800, '7.8 kHz'),
|
||||||
|
bw10_4(10400, '10.4 kHz'),
|
||||||
|
bw15_6(15600, '15.6 kHz'),
|
||||||
|
bw20_8(20800, '20.8 kHz'),
|
||||||
|
bw31_25(31250, '31.25 kHz'),
|
||||||
|
bw41_7(41700, '41.7 kHz'),
|
||||||
|
bw62_5(62500, '62.5 kHz'),
|
||||||
|
bw125(125000, '125 kHz'),
|
||||||
|
bw250(250000, '250 kHz'),
|
||||||
|
bw500(500000, '500 kHz');
|
||||||
|
|
||||||
|
final int hz;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const LoRaBandwidth(this.hz, this.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoRaSpreadingFactor {
|
||||||
|
sf5(5, 'SF5'),
|
||||||
|
sf6(6, 'SF6'),
|
||||||
|
sf7(7, 'SF7'),
|
||||||
|
sf8(8, 'SF8'),
|
||||||
|
sf9(9, 'SF9'),
|
||||||
|
sf10(10, 'SF10'),
|
||||||
|
sf11(11, 'SF11'),
|
||||||
|
sf12(12, 'SF12');
|
||||||
|
|
||||||
|
final int value;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const LoRaSpreadingFactor(this.value, this.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoRaCodingRate {
|
||||||
|
cr4_5(5, '4/5'),
|
||||||
|
cr4_6(6, '4/6'),
|
||||||
|
cr4_7(7, '4/7'),
|
||||||
|
cr4_8(8, '4/8');
|
||||||
|
|
||||||
|
final int value;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const LoRaCodingRate(this.value, this.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
class RadioSettings {
|
||||||
|
final double frequencyMHz;
|
||||||
|
final LoRaBandwidth bandwidth;
|
||||||
|
final LoRaSpreadingFactor spreadingFactor;
|
||||||
|
final LoRaCodingRate codingRate;
|
||||||
|
final int txPowerDbm;
|
||||||
|
|
||||||
|
RadioSettings({
|
||||||
|
required this.frequencyMHz,
|
||||||
|
required this.bandwidth,
|
||||||
|
required this.spreadingFactor,
|
||||||
|
required this.codingRate,
|
||||||
|
required this.txPowerDbm,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preset configurations
|
||||||
|
static RadioSettings get preset915MHz => RadioSettings(
|
||||||
|
frequencyMHz: 915.0,
|
||||||
|
bandwidth: LoRaBandwidth.bw125,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_5,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
static RadioSettings get preset868MHz => RadioSettings(
|
||||||
|
frequencyMHz: 868.0,
|
||||||
|
bandwidth: LoRaBandwidth.bw125,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_5,
|
||||||
|
txPowerDbm: 14,
|
||||||
|
);
|
||||||
|
|
||||||
|
static RadioSettings get preset433MHz => RadioSettings(
|
||||||
|
frequencyMHz: 433.0,
|
||||||
|
bandwidth: LoRaBandwidth.bw125,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_5,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
static RadioSettings get presetLongRange => RadioSettings(
|
||||||
|
frequencyMHz: 915.0,
|
||||||
|
bandwidth: LoRaBandwidth.bw125,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf12,
|
||||||
|
codingRate: LoRaCodingRate.cr4_8,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
static RadioSettings get presetFastSpeed => RadioSettings(
|
||||||
|
frequencyMHz: 915.0,
|
||||||
|
bandwidth: LoRaBandwidth.bw500,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_5,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
int get frequencyHz => (frequencyMHz * 1000).round();
|
||||||
|
int get bandwidthHz => bandwidth.hz;
|
||||||
|
int get sf => spreadingFactor.value;
|
||||||
|
int get cr => codingRate.value;
|
||||||
|
}
|
||||||
@ -0,0 +1,484 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../services/app_settings_service.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
|
|
||||||
|
class AppSettingsScreen extends StatelessWidget {
|
||||||
|
const AppSettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('App Settings'),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: Consumer2<AppSettingsService, MeshCoreConnector>(
|
||||||
|
builder: (context, settingsService, connector, child) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildAppearanceCard(context, settingsService),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildNotificationsCard(context, settingsService),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildMessagingCard(context, settingsService),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildBatteryCard(context, settingsService, connector),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildMapSettingsCard(context, settingsService),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Appearance',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.brightness_6_outlined),
|
||||||
|
title: const Text('Theme'),
|
||||||
|
subtitle: Text(_themeModeLabel(settingsService.settings.themeMode)),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showThemeModeDialog(context, settingsService),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Notifications',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.notifications_outlined),
|
||||||
|
title: const Text('Enable Notifications'),
|
||||||
|
subtitle: const Text('Receive notifications for messages and adverts'),
|
||||||
|
value: settingsService.settings.notificationsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
if (value) {
|
||||||
|
// Request permission when enabling
|
||||||
|
final granted = await NotificationService().requestPermissions();
|
||||||
|
if (!granted) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Notification permission denied'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await settingsService.setNotificationsEnabled(value);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(value
|
||||||
|
? 'Notifications enabled'
|
||||||
|
: 'Notifications disabled'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: Icon(
|
||||||
|
Icons.message_outlined,
|
||||||
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Message Notifications',
|
||||||
|
style: TextStyle(
|
||||||
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show notification when receiving new messages',
|
||||||
|
style: TextStyle(
|
||||||
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
value: settingsService.settings.notifyOnNewMessage,
|
||||||
|
onChanged: settingsService.settings.notificationsEnabled
|
||||||
|
? (value) {
|
||||||
|
settingsService.setNotifyOnNewMessage(value);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: Icon(
|
||||||
|
Icons.cell_tower,
|
||||||
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Advertisement Notifications',
|
||||||
|
style: TextStyle(
|
||||||
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show notification when new nodes are discovered',
|
||||||
|
style: TextStyle(
|
||||||
|
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
value: settingsService.settings.notifyOnNewAdvert,
|
||||||
|
onChanged: settingsService.settings.notificationsEnabled
|
||||||
|
? (value) {
|
||||||
|
settingsService.setNotifyOnNewAdvert(value);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Messaging',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.refresh_outlined),
|
||||||
|
title: const Text('Clear Path on Max Retry'),
|
||||||
|
subtitle: const Text('Reset contact path after 5 failed send attempts'),
|
||||||
|
value: settingsService.settings.clearPathOnMaxRetry,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsService.setClearPathOnMaxRetry(value);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(value
|
||||||
|
? 'Paths will be cleared after 5 failed retries'
|
||||||
|
: 'Paths will not be auto-cleared'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.alt_route),
|
||||||
|
title: const Text('Auto Route Rotation'),
|
||||||
|
subtitle: const Text('Cycle between best paths and flood mode'),
|
||||||
|
value: settingsService.settings.autoRouteRotationEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsService.setAutoRouteRotationEnabled(value);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(value
|
||||||
|
? 'Auto route rotation enabled'
|
||||||
|
: 'Auto route rotation disabled'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Map Display',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.router_outlined),
|
||||||
|
title: const Text('Show Repeaters'),
|
||||||
|
subtitle: const Text('Display repeater nodes on the map'),
|
||||||
|
value: settingsService.settings.mapShowRepeaters,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsService.setMapShowRepeaters(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.chat_outlined),
|
||||||
|
title: const Text('Show Chat Nodes'),
|
||||||
|
subtitle: const Text('Display chat nodes on the map'),
|
||||||
|
value: settingsService.settings.mapShowChatNodes,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsService.setMapShowChatNodes(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.people_outline),
|
||||||
|
title: const Text('Show Other Nodes'),
|
||||||
|
subtitle: const Text('Display other node types on the map'),
|
||||||
|
value: settingsService.settings.mapShowOtherNodes,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsService.setMapShowOtherNodes(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.timer_outlined),
|
||||||
|
title: const Text('Time Filter'),
|
||||||
|
subtitle: Text(
|
||||||
|
settingsService.settings.mapTimeFilterHours == 0
|
||||||
|
? 'Show all nodes'
|
||||||
|
: 'Show nodes from last ${settingsService.settings.mapTimeFilterHours.toInt()} hours',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showTimeFilterDialog(context, settingsService),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBatteryCard(
|
||||||
|
BuildContext context,
|
||||||
|
AppSettingsService settingsService,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
) {
|
||||||
|
final deviceId = connector.device?.remoteId.toString();
|
||||||
|
final isConnected = connector.isConnected && deviceId != null;
|
||||||
|
final selection =
|
||||||
|
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Battery',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.battery_full),
|
||||||
|
title: const Text('Battery Chemistry'),
|
||||||
|
subtitle: Text(
|
||||||
|
isConnected
|
||||||
|
? 'Set per device (${connector.device!.platformName})'
|
||||||
|
: 'Connect to a device to choose',
|
||||||
|
),
|
||||||
|
trailing: DropdownButton<String>(
|
||||||
|
value: selection,
|
||||||
|
onChanged: isConnected
|
||||||
|
? (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsService.setBatteryChemistryForDevice(deviceId, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'nmc',
|
||||||
|
child: Text('18650 NMC (3.0-4.2V)'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'lifepo4',
|
||||||
|
child: Text('LiFePO4 (2.6-3.65V)'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'lipo',
|
||||||
|
child: Text('LiPo (3.0-4.2V)'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Theme'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
RadioListTile<String>(
|
||||||
|
title: const Text('System default'),
|
||||||
|
value: 'system',
|
||||||
|
groupValue: settingsService.settings.themeMode,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsService.setThemeMode(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<String>(
|
||||||
|
title: const Text('Light'),
|
||||||
|
value: 'light',
|
||||||
|
groupValue: settingsService.settings.themeMode,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsService.setThemeMode(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<String>(
|
||||||
|
title: const Text('Dark'),
|
||||||
|
value: 'dark',
|
||||||
|
groupValue: settingsService.settings.themeMode,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsService.setThemeMode(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _themeModeLabel(String value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'light':
|
||||||
|
return 'Light';
|
||||||
|
case 'dark':
|
||||||
|
return 'Dark';
|
||||||
|
default:
|
||||||
|
return 'System default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Map Time Filter'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Show nodes discovered within:'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('All time'),
|
||||||
|
leading: Radio<double>(
|
||||||
|
value: 0,
|
||||||
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsService.setMapTimeFilterHours(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Last hour'),
|
||||||
|
leading: Radio<double>(
|
||||||
|
value: 1,
|
||||||
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsService.setMapTimeFilterHours(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Last 6 hours'),
|
||||||
|
leading: Radio<double>(
|
||||||
|
value: 6,
|
||||||
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsService.setMapTimeFilterHours(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Last 24 hours'),
|
||||||
|
leading: Radio<double>(
|
||||||
|
value: 24,
|
||||||
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsService.setMapTimeFilterHours(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Last week'),
|
||||||
|
leading: Radio<double>(
|
||||||
|
value: 168,
|
||||||
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsService.setMapTimeFilterHours(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,384 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../services/ble_debug_log_service.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
|
enum _BleLogView { frames, rawLogRx }
|
||||||
|
|
||||||
|
class BleDebugLogScreen extends StatefulWidget {
|
||||||
|
const BleDebugLogScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BleDebugLogScreen> createState() => _BleDebugLogScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||||
|
_BleLogView _view = _BleLogView.frames;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<BleDebugLogService>(
|
||||||
|
builder: (context, logService, _) {
|
||||||
|
final entries = logService.entries.reversed.toList();
|
||||||
|
final rawEntries = logService.rawLogRxEntries.reversed.toList();
|
||||||
|
final showingFrames = _view == _BleLogView.frames;
|
||||||
|
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('BLE Debug Log'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Copy log',
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
onPressed: hasEntries
|
||||||
|
? () async {
|
||||||
|
final text = showingFrames
|
||||||
|
? entries
|
||||||
|
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
|
||||||
|
.join('\n')
|
||||||
|
: rawEntries
|
||||||
|
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
|
||||||
|
.join('\n');
|
||||||
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('BLE log copied')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Clear log',
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: hasEntries
|
||||||
|
? () {
|
||||||
|
logService.clear();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: SegmentedButton<_BleLogView>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: _BleLogView.frames, label: Text('Frames')),
|
||||||
|
ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')),
|
||||||
|
],
|
||||||
|
selected: {_view},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
setState(() => _view = selection.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: hasEntries
|
||||||
|
? ListView.separated(
|
||||||
|
itemCount: showingFrames ? entries.length : rawEntries.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (showingFrames) {
|
||||||
|
final entry = entries[index];
|
||||||
|
final time =
|
||||||
|
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(entry.description),
|
||||||
|
subtitle: Text('${entry.hexPreview}\n$time'),
|
||||||
|
isThreeLine: true,
|
||||||
|
leading: Icon(
|
||||||
|
entry.outgoing ? Icons.upload : Icons.download,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final entry = rawEntries[index];
|
||||||
|
final info = _decodeRawPacket(entry.payload);
|
||||||
|
final time =
|
||||||
|
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(info.title),
|
||||||
|
subtitle: Text('${info.summary}\n$time'),
|
||||||
|
isThreeLine: true,
|
||||||
|
leading: const Icon(Icons.download, size: 18),
|
||||||
|
onTap: () => _showRawDialog(context, info),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: const Center(
|
||||||
|
child: Text('No BLE activity yet'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRawDialog(BuildContext context, _RawPacketInfo info) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(info.title),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: SelectableText(info.rawHex),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_RawPacketInfo _decodeRawPacket(Uint8List raw) {
|
||||||
|
if (raw.length < 2) {
|
||||||
|
return _RawPacketInfo(
|
||||||
|
title: 'RX RAW_LOG_RX_DATA • invalid',
|
||||||
|
summary: 'Packet too short',
|
||||||
|
rawHex: _bytesToHex(raw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = 0;
|
||||||
|
final header = raw[index++];
|
||||||
|
final routeType = header & 0x03;
|
||||||
|
final payloadType = (header >> 2) & 0x0F;
|
||||||
|
final payloadVer = (header >> 6) & 0x03;
|
||||||
|
final hasTransport = routeType == 0 || routeType == 3;
|
||||||
|
if (hasTransport) {
|
||||||
|
if (raw.length < index + 4) {
|
||||||
|
return _RawPacketInfo(
|
||||||
|
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||||
|
summary: 'Missing transport codes',
|
||||||
|
rawHex: _bytesToHex(raw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
index += 4;
|
||||||
|
}
|
||||||
|
if (raw.length <= index) {
|
||||||
|
return _RawPacketInfo(
|
||||||
|
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||||
|
summary: 'Missing path length',
|
||||||
|
rawHex: _bytesToHex(raw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final pathLen = raw[index++];
|
||||||
|
if (raw.length < index + pathLen) {
|
||||||
|
return _RawPacketInfo(
|
||||||
|
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||||
|
summary: 'Truncated path',
|
||||||
|
rawHex: _bytesToHex(raw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final pathBytes = raw.sublist(index, index + pathLen);
|
||||||
|
index += pathLen;
|
||||||
|
if (raw.length <= index) {
|
||||||
|
return _RawPacketInfo(
|
||||||
|
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||||
|
summary: 'Missing payload',
|
||||||
|
rawHex: _bytesToHex(raw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final payload = raw.sublist(index);
|
||||||
|
|
||||||
|
final title = 'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||||
|
final summary = _decodePayloadSummary(payloadType, payload);
|
||||||
|
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
|
||||||
|
final detail = '$summary • $pathSummary • len=${raw.length}';
|
||||||
|
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _decodePayloadSummary(int payloadType, Uint8List payload) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00: // REQ
|
||||||
|
return 'REQ payload=${payload.length} bytes';
|
||||||
|
case 0x01: // RESP
|
||||||
|
return 'RESP payload=${payload.length} bytes';
|
||||||
|
case 0x02: // TXT
|
||||||
|
return 'TXT payload=${payload.length} bytes';
|
||||||
|
case 0x03: // ACK
|
||||||
|
if (payload.length < 4) return 'ACK (short)';
|
||||||
|
return 'ACK crc=${_bytesToHex(payload.sublist(0, 4))}';
|
||||||
|
case 0x04: // ADVERT
|
||||||
|
return _decodeAdvertSummary(payload);
|
||||||
|
case 0x05: // GROUP_TXT
|
||||||
|
if (payload.length < 3) return 'GRP_TXT (short)';
|
||||||
|
final channelHash = payload[0].toRadixString(16).padLeft(2, '0');
|
||||||
|
final mac = _bytesToHex(payload.sublist(1, 3));
|
||||||
|
final cipherLen = payload.length - 3;
|
||||||
|
return 'GRP_TXT hash=$channelHash mac=$mac cipher=$cipherLen';
|
||||||
|
case 0x06: // GROUP_DATA
|
||||||
|
return 'GRP_DATA payload=${payload.length} bytes';
|
||||||
|
case 0x07: // ANON_REQ
|
||||||
|
return 'ANON_REQ payload=${payload.length} bytes';
|
||||||
|
case 0x08: // PATH
|
||||||
|
return 'PATH payload=${payload.length} bytes';
|
||||||
|
case 0x09: // TRACE
|
||||||
|
return 'TRACE payload=${payload.length} bytes';
|
||||||
|
case 0x0A: // MULTIPART
|
||||||
|
return 'MULTIPART payload=${payload.length} bytes';
|
||||||
|
case 0x0B: // CONTROL
|
||||||
|
return _decodeControlSummary(payload);
|
||||||
|
case 0x0F: // RAW
|
||||||
|
return 'RAW payload=${payload.length} bytes';
|
||||||
|
default:
|
||||||
|
return 'TYPE_$payloadType payload=${payload.length} bytes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _decodeAdvertSummary(Uint8List payload) {
|
||||||
|
if (payload.length < 101) {
|
||||||
|
return 'ADVERT (short)';
|
||||||
|
}
|
||||||
|
var offset = 0;
|
||||||
|
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false);
|
||||||
|
offset += 32;
|
||||||
|
final timestamp = readUint32LE(payload, offset);
|
||||||
|
offset += 4;
|
||||||
|
offset += 64; // signature
|
||||||
|
final flags = payload[offset++];
|
||||||
|
final role = _deviceRoleLabel(flags & 0x0F);
|
||||||
|
final hasLocation = (flags & 0x10) != 0;
|
||||||
|
final hasFeature1 = (flags & 0x20) != 0;
|
||||||
|
final hasFeature2 = (flags & 0x40) != 0;
|
||||||
|
final hasName = (flags & 0x80) != 0;
|
||||||
|
String? name;
|
||||||
|
double? lat;
|
||||||
|
double? lon;
|
||||||
|
if (hasLocation && payload.length >= offset + 8) {
|
||||||
|
lat = readInt32LE(payload, offset) / 1000000.0;
|
||||||
|
lon = readInt32LE(payload, offset + 4) / 1000000.0;
|
||||||
|
offset += 8;
|
||||||
|
}
|
||||||
|
if (hasFeature1) offset += 2;
|
||||||
|
if (hasFeature2) offset += 2;
|
||||||
|
if (hasName && payload.length > offset) {
|
||||||
|
final rawName = String.fromCharCodes(payload.sublist(offset));
|
||||||
|
final nul = rawName.indexOf('\u0000');
|
||||||
|
name = nul >= 0 ? rawName.substring(0, nul) : rawName;
|
||||||
|
name = name.trim();
|
||||||
|
}
|
||||||
|
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
|
||||||
|
final locPart = (lat != null && lon != null)
|
||||||
|
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
|
||||||
|
: '';
|
||||||
|
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}…';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _decodeControlSummary(Uint8List payload) {
|
||||||
|
if (payload.isEmpty) return 'CONTROL (empty)';
|
||||||
|
final flags = payload[0];
|
||||||
|
final subType = flags & 0xF0;
|
||||||
|
if (subType == 0x80) {
|
||||||
|
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
|
||||||
|
final typeFilter = payload[1];
|
||||||
|
final tag = readUint32LE(payload, 2);
|
||||||
|
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
|
||||||
|
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
|
||||||
|
}
|
||||||
|
if (subType == 0x90) {
|
||||||
|
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
|
||||||
|
final nodeType = flags & 0x0F;
|
||||||
|
final snrRaw = payload[1];
|
||||||
|
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
|
||||||
|
final snr = snrSigned / 4.0;
|
||||||
|
final tag = readUint32LE(payload, 2);
|
||||||
|
final keyLen = payload.length - 6;
|
||||||
|
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
|
||||||
|
}
|
||||||
|
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _payloadTypeLabel(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'REQ';
|
||||||
|
case 0x01:
|
||||||
|
return 'RESP';
|
||||||
|
case 0x02:
|
||||||
|
return 'TXT';
|
||||||
|
case 0x03:
|
||||||
|
return 'ACK';
|
||||||
|
case 0x04:
|
||||||
|
return 'ADVERT';
|
||||||
|
case 0x05:
|
||||||
|
return 'GRP_TXT';
|
||||||
|
case 0x06:
|
||||||
|
return 'GRP_DATA';
|
||||||
|
case 0x07:
|
||||||
|
return 'ANON_REQ';
|
||||||
|
case 0x08:
|
||||||
|
return 'PATH';
|
||||||
|
case 0x09:
|
||||||
|
return 'TRACE';
|
||||||
|
case 0x0A:
|
||||||
|
return 'MULTIPART';
|
||||||
|
case 0x0B:
|
||||||
|
return 'CONTROL';
|
||||||
|
case 0x0F:
|
||||||
|
return 'RAW';
|
||||||
|
default:
|
||||||
|
return 'TYPE_$payloadType';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _routeLabel(int routeType) {
|
||||||
|
switch (routeType) {
|
||||||
|
case 0:
|
||||||
|
return 'TRANS_FLOOD';
|
||||||
|
case 1:
|
||||||
|
return 'FLOOD';
|
||||||
|
case 2:
|
||||||
|
return 'DIRECT';
|
||||||
|
case 3:
|
||||||
|
return 'TRANS_DIRECT';
|
||||||
|
default:
|
||||||
|
return 'ROUTE_$routeType';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _deviceRoleLabel(int role) {
|
||||||
|
switch (role) {
|
||||||
|
case 0x01:
|
||||||
|
return 'Chat';
|
||||||
|
case 0x02:
|
||||||
|
return 'Repeater';
|
||||||
|
case 0x03:
|
||||||
|
return 'Room';
|
||||||
|
case 0x04:
|
||||||
|
return 'Sensor';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _bytesToHex(Uint8List bytes, {bool spaced = true}) {
|
||||||
|
if (bytes.isEmpty) return '';
|
||||||
|
if (!spaced) {
|
||||||
|
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
|
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RawPacketInfo {
|
||||||
|
final String title;
|
||||||
|
final String summary;
|
||||||
|
final String rawHex;
|
||||||
|
|
||||||
|
_RawPacketInfo({
|
||||||
|
required this.title,
|
||||||
|
required this.summary,
|
||||||
|
required this.rawHex,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,555 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../models/channel.dart';
|
||||||
|
import '../models/channel_message.dart';
|
||||||
|
import '../utils/emoji_utils.dart';
|
||||||
|
import '../widgets/gif_message.dart';
|
||||||
|
import '../widgets/gif_picker.dart';
|
||||||
|
import 'map_screen.dart';
|
||||||
|
|
||||||
|
class ChannelChatScreen extends StatefulWidget {
|
||||||
|
final Channel channel;
|
||||||
|
|
||||||
|
const ChannelChatScreen({
|
||||||
|
super.key,
|
||||||
|
required this.channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||||
|
final TextEditingController _textController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_textController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.channel.name.isEmpty
|
||||||
|
? 'Channel ${widget.channel.index}'
|
||||||
|
: widget.channel.name,
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.channel.isPublicChannel ? 'Public' : 'Private',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
centerTitle: false,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
final messages = connector.getChannelMessages(widget.channel);
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (messages.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
widget.channel.isPublicChannel
|
||||||
|
? Icons.public
|
||||||
|
: Icons.tag,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No messages yet',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Send a message to get started',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
cacheExtent: 0,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
itemCount: messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = messages[index];
|
||||||
|
return _buildMessageBubble(message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildMessageComposer(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageBubble(ChannelMessage message) {
|
||||||
|
final isOutgoing = message.isOutgoing;
|
||||||
|
final gifId = _parseGifId(message.text);
|
||||||
|
final poi = _parsePoiMessage(message.text);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (!isOutgoing) ...[
|
||||||
|
_buildAvatar(message.senderName),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Flexible(
|
||||||
|
child: GestureDetector(
|
||||||
|
onLongPress: () => _showMessagePathInfo(message),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isOutgoing
|
||||||
|
? Theme.of(context).colorScheme.primaryContainer
|
||||||
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (!isOutgoing) ...[
|
||||||
|
Text(
|
||||||
|
message.senderName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
if (poi != null)
|
||||||
|
_buildPoiMessage(context, poi, isOutgoing)
|
||||||
|
else if (gifId != null)
|
||||||
|
GifMessage(
|
||||||
|
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
fallbackTextColor: isOutgoing
|
||||||
|
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
|
||||||
|
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
message.text,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
if (message.pathBytes.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'via ${_formatPathPrefixes(message.pathBytes)}',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatTime(message.timestamp),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (message.repeatCount > 0) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(Icons.repeat, size: 12, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
'${message.repeatCount}',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (isOutgoing) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
message.status == ChannelMessageStatus.sent
|
||||||
|
? Icons.check
|
||||||
|
: message.status == ChannelMessageStatus.pending
|
||||||
|
? Icons.schedule
|
||||||
|
: Icons.error_outline,
|
||||||
|
size: 14,
|
||||||
|
color: message.status == ChannelMessageStatus.failed
|
||||||
|
? Colors.red
|
||||||
|
: Colors.grey[600],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _parseGifId(String text) {
|
||||||
|
final trimmed = text.trim();
|
||||||
|
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
|
||||||
|
return match?.group(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_PoiInfo? _parsePoiMessage(String text) {
|
||||||
|
final trimmed = text.trim();
|
||||||
|
final match = RegExp(r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|').firstMatch(trimmed);
|
||||||
|
if (match == null) return null;
|
||||||
|
final lat = double.tryParse(match.group(1) ?? '');
|
||||||
|
final lon = double.tryParse(match.group(2) ?? '');
|
||||||
|
if (lat == null || lon == null) return null;
|
||||||
|
final label = match.group(3) ?? '';
|
||||||
|
return _PoiInfo(lat: lat, lon: lon, label: label);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textColor =
|
||||||
|
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
|
||||||
|
final metaColor = textColor.withValues(alpha: 0.7);
|
||||||
|
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.location_on_outlined, color: channelColor),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => MapScreen(
|
||||||
|
highlightPosition: LatLng(poi.lat, poi.lon),
|
||||||
|
highlightLabel: poi.label,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'POI Shared',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (poi.label.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
poi.label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: metaColor,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showGifPicker(BuildContext context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => GifPicker(
|
||||||
|
onGifSelected: (gifId) {
|
||||||
|
_textController.text = 'g:$gifId';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatar(String senderName) {
|
||||||
|
final initial = _getFirstCharacterOrEmoji(senderName);
|
||||||
|
final color = _getColorForName(senderName);
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 18,
|
||||||
|
backgroundColor: color.withValues(alpha: 0.2),
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getFirstCharacterOrEmoji(String name) {
|
||||||
|
if (name.isEmpty) return '?';
|
||||||
|
|
||||||
|
final emoji = firstEmoji(name);
|
||||||
|
if (emoji != null) return emoji;
|
||||||
|
|
||||||
|
final runes = name.runes.toList();
|
||||||
|
if (runes.isEmpty) return '?';
|
||||||
|
return String.fromCharCode(runes[0]).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getColorForName(String name) {
|
||||||
|
// Generate a consistent color based on the name hash
|
||||||
|
final hash = name.hashCode;
|
||||||
|
final colors = [
|
||||||
|
Colors.blue,
|
||||||
|
Colors.green,
|
||||||
|
Colors.orange,
|
||||||
|
Colors.purple,
|
||||||
|
Colors.pink,
|
||||||
|
Colors.teal,
|
||||||
|
Colors.indigo,
|
||||||
|
Colors.cyan,
|
||||||
|
Colors.amber,
|
||||||
|
Colors.deepOrange,
|
||||||
|
];
|
||||||
|
|
||||||
|
return colors[hash.abs() % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageComposer() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.gif_box),
|
||||||
|
onPressed: () => _showGifPicker(context),
|
||||||
|
tooltip: 'Send GIF',
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ValueListenableBuilder<TextEditingValue>(
|
||||||
|
valueListenable: _textController,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
final gifId = _parseGifId(value.text);
|
||||||
|
if (gifId != null) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: GifMessage(
|
||||||
|
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
fallbackTextColor:
|
||||||
|
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
width: 160,
|
||||||
|
height: 110,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => _textController.clear(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextField(
|
||||||
|
controller: _textController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Type a message...',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: null,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
onPressed: _sendMessage,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendMessage() {
|
||||||
|
final text = _textController.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
|
context.read<MeshCoreConnector>().sendChannelMessage(widget.channel, text);
|
||||||
|
_textController.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime time) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(time);
|
||||||
|
|
||||||
|
if (diff.inDays > 0) {
|
||||||
|
return '${time.day}/${time.month} ${time.hour}:${time.minute.toString().padLeft(2, '0')}';
|
||||||
|
} else {
|
||||||
|
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMessagePathInfo(ChannelMessage message) {
|
||||||
|
final pathPrefixes =
|
||||||
|
message.pathBytes.isNotEmpty ? _formatPathPrefixes(message.pathBytes) : null;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Packet Path'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildDetailRow('Sender', message.senderName),
|
||||||
|
_buildDetailRow('Time', _formatTime(message.timestamp)),
|
||||||
|
_buildDetailRow('Repeats', message.repeatCount.toString()),
|
||||||
|
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
|
||||||
|
if (pathPrefixes != null) _buildDetailRow('Prefixes', pathPrefixes),
|
||||||
|
if (pathPrefixes == null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Hop details are not provided for this packet.',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatPathLabel(int? pathLength) {
|
||||||
|
if (pathLength == null) return 'Unknown';
|
||||||
|
if (pathLength < 0) return 'Flood';
|
||||||
|
if (pathLength == 0) return 'Direct';
|
||||||
|
return '$pathLength hops';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||||
|
return pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 70,
|
||||||
|
child: Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||||
|
),
|
||||||
|
Expanded(child: Text(value)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PoiInfo {
|
||||||
|
final double lat;
|
||||||
|
final double lon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _PoiInfo({
|
||||||
|
required this.lat,
|
||||||
|
required this.lon,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,439 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../models/channel.dart';
|
||||||
|
import 'channel_chat_screen.dart';
|
||||||
|
|
||||||
|
class ChannelsScreen extends StatefulWidget {
|
||||||
|
const ChannelsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChannelsScreen> createState() => _ChannelsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<MeshCoreConnector>().getChannels();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Channels'),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () => context.read<MeshCoreConnector>().getChannels(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
if (connector.isLoadingChannels) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final channels = connector.channels;
|
||||||
|
|
||||||
|
if (channels.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.tag, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No channels configured',
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _addPublicChannel(context, connector),
|
||||||
|
icon: const Icon(Icons.public),
|
||||||
|
label: const Text('Add Public Channel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReorderableListView.builder(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
itemCount: channels.length,
|
||||||
|
onReorder: (oldIndex, newIndex) async {
|
||||||
|
if (newIndex > oldIndex) newIndex -= 1;
|
||||||
|
final reordered = List<Channel>.from(channels);
|
||||||
|
final item = reordered.removeAt(oldIndex);
|
||||||
|
reordered.insert(newIndex, item);
|
||||||
|
await connector.setChannelOrder(
|
||||||
|
reordered.map((c) => c.index).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final channel = channels[index];
|
||||||
|
return _buildChannelTile(context, connector, channel);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showAddChannelDialog(context),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChannelTile(
|
||||||
|
BuildContext context,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
Channel channel,
|
||||||
|
) {
|
||||||
|
return Card(
|
||||||
|
key: ValueKey('channel_${channel.index}'),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: channel.isPublicChannel
|
||||||
|
? Colors.green.withValues(alpha: 0.2)
|
||||||
|
: Colors.blue.withValues(alpha: 0.2),
|
||||||
|
child: Icon(
|
||||||
|
channel.isPublicChannel
|
||||||
|
? Icons.public
|
||||||
|
: channel.name.startsWith('#')
|
||||||
|
? Icons.tag
|
||||||
|
: Icons.lock,
|
||||||
|
color: channel.isPublicChannel ? Colors.green : Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
channel.name.startsWith('#')
|
||||||
|
? 'Hashtag channel'
|
||||||
|
: channel.isPublicChannel
|
||||||
|
? 'Public channel'
|
||||||
|
: 'Private channel',
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
onPressed: () => _showEditChannelDialog(context, connector, channel),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'delete') {
|
||||||
|
_confirmDeleteChannel(context, connector, channel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ChannelChatScreen(channel: channel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddChannelDialog(BuildContext context) {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
final nameController = TextEditingController();
|
||||||
|
final pskController = TextEditingController();
|
||||||
|
final maxChannels = connector.maxChannels;
|
||||||
|
int selectedIndex = _findNextAvailableIndex(connector.channels, maxChannels);
|
||||||
|
bool usePublicPsk = false;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: const Text('Add Channel'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
initialValue: selectedIndex,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Channel Index',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: List.generate(maxChannels, (i) => i)
|
||||||
|
.map((i) => DropdownMenuItem(
|
||||||
|
value: i,
|
||||||
|
child: Text('Channel $i'),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setDialogState(() => selectedIndex = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Channel Name',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLength: 31,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Use Public Channel'),
|
||||||
|
subtitle: const Text('Standard public PSK'),
|
||||||
|
value: usePublicPsk,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() {
|
||||||
|
usePublicPsk = value ?? false;
|
||||||
|
if (usePublicPsk) {
|
||||||
|
nameController.text = 'Public';
|
||||||
|
pskController.text = Channel.publicChannelPsk;
|
||||||
|
} else {
|
||||||
|
pskController.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!usePublicPsk) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: pskController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'PSK (Base64)',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.casino),
|
||||||
|
tooltip: 'Generate random PSK',
|
||||||
|
onPressed: () {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = Uint8List(16);
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
bytes[i] = random.nextInt(256);
|
||||||
|
}
|
||||||
|
pskController.text = base64Encode(bytes);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final name = nameController.text.trim();
|
||||||
|
final pskBase64 = usePublicPsk
|
||||||
|
? Channel.publicChannelPsk
|
||||||
|
: pskController.text.trim();
|
||||||
|
|
||||||
|
if (name.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please enter a channel name')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List psk;
|
||||||
|
try {
|
||||||
|
final decoded = base64Decode(pskBase64);
|
||||||
|
psk = Uint8List(16);
|
||||||
|
for (int i = 0; i < decoded.length && i < 16; i++) {
|
||||||
|
psk[i] = decoded[i];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Invalid PSK format')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.pop(context);
|
||||||
|
connector.setChannel(selectedIndex, name, psk);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Channel "$name" added')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditChannelDialog(
|
||||||
|
BuildContext context,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
Channel channel,
|
||||||
|
) {
|
||||||
|
final nameController = TextEditingController(text: channel.name);
|
||||||
|
final pskController = TextEditingController(text: channel.pskBase64);
|
||||||
|
bool smazEnabled = connector.isChannelSmazEnabled(channel.index);
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: Text('Edit Channel ${channel.index}'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Channel Name',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLength: 31,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: pskController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'PSK (Base64)',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.casino),
|
||||||
|
tooltip: 'Generate random PSK',
|
||||||
|
onPressed: () {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = Uint8List(16);
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
bytes[i] = random.nextInt(256);
|
||||||
|
}
|
||||||
|
pskController.text = base64Encode(bytes);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('SMAZ compression'),
|
||||||
|
value: smazEnabled,
|
||||||
|
onChanged: (value) => setState(() => smazEnabled = value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final name = nameController.text.trim();
|
||||||
|
final pskBase64 = pskController.text.trim();
|
||||||
|
|
||||||
|
Uint8List psk;
|
||||||
|
try {
|
||||||
|
final decoded = base64Decode(pskBase64);
|
||||||
|
psk = Uint8List(16);
|
||||||
|
for (int i = 0; i < decoded.length && i < 16; i++) {
|
||||||
|
psk[i] = decoded[i];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Invalid PSK format')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.pop(context);
|
||||||
|
connector.setChannel(channel.index, name, psk);
|
||||||
|
connector.setChannelSmazEnabled(channel.index, smazEnabled);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Channel "$name" updated')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDeleteChannel(
|
||||||
|
BuildContext context,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
Channel channel,
|
||||||
|
) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Delete Channel'),
|
||||||
|
content: Text('Delete "${channel.name}"? This cannot be undone.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
connector.deleteChannel(channel.index);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Channel "${channel.name}" deleted')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final psk = Uint8List(16);
|
||||||
|
final decoded = base64Decode(Channel.publicChannelPsk);
|
||||||
|
for (int i = 0; i < decoded.length && i < 16; i++) {
|
||||||
|
psk[i] = decoded[i];
|
||||||
|
}
|
||||||
|
connector.setChannel(0, 'Public', psk);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Public channel added')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _findNextAvailableIndex(List<Channel> channels, int maxChannels) {
|
||||||
|
final usedIndices = channels.map((c) => c.index).toSet();
|
||||||
|
for (int i = 0; i < maxChannels; i++) {
|
||||||
|
if (!usedIndices.contains(i)) return i;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,791 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
|
import '../storage/contact_group_store.dart';
|
||||||
|
import '../widgets/repeater_login_dialog.dart';
|
||||||
|
import '../utils/emoji_utils.dart';
|
||||||
|
import 'chat_screen.dart';
|
||||||
|
import 'repeater_hub_screen.dart';
|
||||||
|
|
||||||
|
enum ContactSortOption {
|
||||||
|
lastSeen,
|
||||||
|
recentMessages,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactsScreen extends StatefulWidget {
|
||||||
|
const ContactsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ContactsScreen> createState() => _ContactsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
String _searchQuery = '';
|
||||||
|
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
||||||
|
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||||
|
List<ContactGroup> _groups = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadGroups() async {
|
||||||
|
final groups = await _groupStore.loadGroups();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_groups = groups;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveGroups() async {
|
||||||
|
await _groupStore.saveGroups(_groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Contacts'),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<ContactSortOption>(
|
||||||
|
icon: const Icon(Icons.sort),
|
||||||
|
tooltip: 'Sort by',
|
||||||
|
onSelected: (option) {
|
||||||
|
setState(() {
|
||||||
|
_sortOption = option;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: ContactSortOption.lastSeen,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 20,
|
||||||
|
color: _sortOption == ContactSortOption.lastSeen
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Last Seen',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: _sortOption == ContactSortOption.lastSeen
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: ContactSortOption.recentMessages,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.chat_bubble,
|
||||||
|
size: 20,
|
||||||
|
color: _sortOption == ContactSortOption.recentMessages
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Recent Messages',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: _sortOption == ContactSortOption.recentMessages
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: ContactSortOption.name,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.sort_by_alpha,
|
||||||
|
size: 20,
|
||||||
|
color: _sortOption == ContactSortOption.name
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Name',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: _sortOption == ContactSortOption.name
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: ContactSortOption.type,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.category,
|
||||||
|
size: 20,
|
||||||
|
color: _sortOption == ContactSortOption.type
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Type',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: _sortOption == ContactSortOption.type
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.group_add),
|
||||||
|
tooltip: 'New group',
|
||||||
|
onPressed: () {
|
||||||
|
final contacts = context.read<MeshCoreConnector>().contacts;
|
||||||
|
_showGroupEditor(context, contacts);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
return IconButton(
|
||||||
|
icon: connector.isLoadingContacts
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
onPressed: connector.isLoadingContacts
|
||||||
|
? null
|
||||||
|
: () => connector.getContacts(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
final contacts = connector.contacts;
|
||||||
|
|
||||||
|
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contacts.isEmpty && _groups.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No contacts yet',
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Contacts will appear when devices advertise',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
||||||
|
final filteredGroups = _filterAndSortGroups(_groups, contacts);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search contacts...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = value.toLowerCase();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No contacts or groups found',
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: () => connector.getContacts(),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: filteredGroups.length + filteredAndSorted.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index < filteredGroups.length) {
|
||||||
|
final group = filteredGroups[index];
|
||||||
|
return _buildGroupTile(context, group, contacts);
|
||||||
|
}
|
||||||
|
final contact = filteredAndSorted[index - filteredGroups.length];
|
||||||
|
return _ContactTile(
|
||||||
|
contact: contact,
|
||||||
|
onTap: () => _openChat(context, contact),
|
||||||
|
onLongPress: () => _showContactOptions(context, connector, contact),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ContactGroup> _filterAndSortGroups(List<ContactGroup> groups, List<Contact> contacts) {
|
||||||
|
final query = _searchQuery.trim().toLowerCase();
|
||||||
|
final contactNames = <String, String>{};
|
||||||
|
for (final contact in contacts) {
|
||||||
|
contactNames[contact.publicKeyHex] = contact.name.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
final filtered = groups.where((group) {
|
||||||
|
if (query.isEmpty) return true;
|
||||||
|
if (group.name.toLowerCase().contains(query)) return true;
|
||||||
|
for (final key in group.memberKeys) {
|
||||||
|
final name = contactNames[key];
|
||||||
|
if (name != null && name.contains(query)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> _filterAndSortContacts(List<Contact> contacts, MeshCoreConnector connector) {
|
||||||
|
var filtered = contacts.where((contact) {
|
||||||
|
if (_searchQuery.isEmpty) return true;
|
||||||
|
return contact.name.toLowerCase().contains(_searchQuery);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
switch (_sortOption) {
|
||||||
|
case ContactSortOption.lastSeen:
|
||||||
|
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||||
|
break;
|
||||||
|
case ContactSortOption.recentMessages:
|
||||||
|
filtered.sort((a, b) {
|
||||||
|
final aMessages = connector.getMessages(a);
|
||||||
|
final bMessages = connector.getMessages(b);
|
||||||
|
final aLastMsg = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
|
||||||
|
final bLastMsg = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
|
||||||
|
return bLastMsg.compareTo(aLastMsg);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ContactSortOption.name:
|
||||||
|
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
break;
|
||||||
|
case ContactSortOption.type:
|
||||||
|
filtered.sort((a, b) {
|
||||||
|
final typeCompare = a.type.compareTo(b.type);
|
||||||
|
if (typeCompare != 0) return typeCompare;
|
||||||
|
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
|
||||||
|
final memberContacts = _resolveGroupContacts(group, contacts);
|
||||||
|
final subtitle = _formatGroupMembers(memberContacts);
|
||||||
|
return ListTile(
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
backgroundColor: Colors.teal,
|
||||||
|
child: Icon(Icons.group, color: Colors.white, size: 20),
|
||||||
|
),
|
||||||
|
title: Text(group.name),
|
||||||
|
subtitle: Text(subtitle),
|
||||||
|
trailing: Text(
|
||||||
|
memberContacts.length.toString(),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
onTap: () => _showGroupOptions(context, group, contacts),
|
||||||
|
onLongPress: () => _showGroupOptions(context, group, contacts),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> _resolveGroupContacts(ContactGroup group, List<Contact> contacts) {
|
||||||
|
final byKey = <String, Contact>{};
|
||||||
|
for (final contact in contacts) {
|
||||||
|
byKey[contact.publicKeyHex] = contact;
|
||||||
|
}
|
||||||
|
final resolved = <Contact>[];
|
||||||
|
for (final key in group.memberKeys) {
|
||||||
|
final contact = byKey[key];
|
||||||
|
if (contact != null) {
|
||||||
|
resolved.add(contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatGroupMembers(List<Contact> members) {
|
||||||
|
if (members.isEmpty) return 'No members';
|
||||||
|
final names = members.map((c) => c.name).toList();
|
||||||
|
if (names.length <= 2) return names.join(', ');
|
||||||
|
return '${names.take(2).join(', ')} +${names.length - 2}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openChat(BuildContext context, Contact contact) {
|
||||||
|
// Check if this is a repeater
|
||||||
|
if (contact.type == advTypeRepeater) {
|
||||||
|
_showRepeaterLogin(context, contact);
|
||||||
|
} else {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRepeaterLogin(BuildContext context, Contact repeater) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => RepeaterLoginDialog(
|
||||||
|
repeater: repeater,
|
||||||
|
onLogin: (password) {
|
||||||
|
// Navigate to repeater hub screen after successful login
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => RepeaterHubScreen(
|
||||||
|
repeater: repeater,
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showGroupOptions(BuildContext context, ContactGroup group, List<Contact> contacts) {
|
||||||
|
final members = _resolveGroupContacts(group, contacts);
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit),
|
||||||
|
title: const Text('Edit Group'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_showGroupEditor(context, contacts, group: group);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
title: const Text('Delete Group', style: TextStyle(color: Colors.red)),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_confirmDeleteGroup(context, group);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (members.isNotEmpty) const Divider(),
|
||||||
|
...members.map((member) {
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.person),
|
||||||
|
title: Text(member.name),
|
||||||
|
subtitle: Text(member.typeLabel),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_openChat(context, member);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Delete Group'),
|
||||||
|
content: Text('Remove "${group.name}"?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
setState(() {
|
||||||
|
_groups.removeWhere((g) => g.name == group.name);
|
||||||
|
});
|
||||||
|
await _saveGroups();
|
||||||
|
},
|
||||||
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showGroupEditor(
|
||||||
|
BuildContext context,
|
||||||
|
List<Contact> contacts, {
|
||||||
|
ContactGroup? group,
|
||||||
|
}) {
|
||||||
|
final isEditing = group != null;
|
||||||
|
final nameController = TextEditingController(text: group?.name ?? '');
|
||||||
|
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
||||||
|
String filterQuery = '';
|
||||||
|
final sortedContacts = List<Contact>.from(contacts)
|
||||||
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
|
builder: (builderContext, setDialogState) {
|
||||||
|
final filteredContacts = filterQuery.isEmpty
|
||||||
|
? sortedContacts
|
||||||
|
: sortedContacts
|
||||||
|
.where((contact) => contact.name.toLowerCase().contains(filterQuery))
|
||||||
|
.toList();
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(isEditing ? 'Edit Group' : 'New Group'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Group name',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Filter contacts...',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() {
|
||||||
|
filterQuery = value.toLowerCase();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 240,
|
||||||
|
child: filteredContacts.isEmpty
|
||||||
|
? const Center(child: Text('No contacts match your filter'))
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: filteredContacts.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final contact = filteredContacts[index];
|
||||||
|
final isSelected = selectedKeys.contains(contact.publicKeyHex);
|
||||||
|
return CheckboxListTile(
|
||||||
|
value: isSelected,
|
||||||
|
title: Text(contact.name),
|
||||||
|
subtitle: Text(contact.typeLabel),
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() {
|
||||||
|
if (value == true) {
|
||||||
|
selectedKeys.add(contact.publicKeyHex);
|
||||||
|
} else {
|
||||||
|
selectedKeys.remove(contact.publicKeyHex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final name = nameController.text.trim();
|
||||||
|
if (name.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Group name is required')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final exists = _groups.any((g) {
|
||||||
|
if (isEditing && g.name == group!.name) return false;
|
||||||
|
return g.name.toLowerCase() == name.toLowerCase();
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Group "$name" already exists')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
if (isEditing) {
|
||||||
|
final index = _groups.indexWhere((g) => g.name == group!.name);
|
||||||
|
if (index != -1) {
|
||||||
|
_groups[index] = ContactGroup(
|
||||||
|
name: name,
|
||||||
|
memberKeys: selectedKeys.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_groups.add(ContactGroup(name: name, memberKeys: selectedKeys.toList()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await _saveGroups();
|
||||||
|
if (dialogContext.mounted) {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(isEditing ? 'Save' : 'Create'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showContactOptions(
|
||||||
|
BuildContext context,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
Contact contact,
|
||||||
|
) {
|
||||||
|
final isRepeater = contact.type == advTypeRepeater;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (isRepeater)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.cell_tower, color: Colors.orange),
|
||||||
|
title: const Text('Manage Repeater'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showRepeaterLogin(context, contact);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.chat),
|
||||||
|
title: const Text('Open Chat'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_openChat(context, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
title: const Text('Delete Contact', style: TextStyle(color: Colors.red)),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_confirmDelete(context, connector, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDelete(
|
||||||
|
BuildContext context,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
Contact contact,
|
||||||
|
) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Delete Contact'),
|
||||||
|
content: Text('Remove ${contact.name} from contacts?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
connector.removeContact(contact);
|
||||||
|
},
|
||||||
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactTile extends StatelessWidget {
|
||||||
|
final Contact contact;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
const _ContactTile({
|
||||||
|
required this.contact,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: _getTypeColor(contact.type),
|
||||||
|
child: _buildContactAvatar(contact),
|
||||||
|
),
|
||||||
|
title: Text(contact.name),
|
||||||
|
subtitle: Text('${contact.typeLabel} • ${contact.pathLabel}'),
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatLastSeen(contact.lastSeen),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
if (contact.hasLocation)
|
||||||
|
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContactAvatar(Contact contact) {
|
||||||
|
final emoji = firstEmoji(contact.name);
|
||||||
|
if (emoji != null) {
|
||||||
|
return Text(
|
||||||
|
emoji,
|
||||||
|
style: const TextStyle(fontSize: 18),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getTypeIcon(int type) {
|
||||||
|
switch (type) {
|
||||||
|
case advTypeChat:
|
||||||
|
return Icons.chat;
|
||||||
|
case advTypeRepeater:
|
||||||
|
return Icons.cell_tower;
|
||||||
|
case advTypeRoom:
|
||||||
|
return Icons.group;
|
||||||
|
case advTypeSensor:
|
||||||
|
return Icons.sensors;
|
||||||
|
default:
|
||||||
|
return Icons.device_unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getTypeColor(int type) {
|
||||||
|
switch (type) {
|
||||||
|
case advTypeChat:
|
||||||
|
return Colors.blue;
|
||||||
|
case advTypeRepeater:
|
||||||
|
return Colors.orange;
|
||||||
|
case advTypeRoom:
|
||||||
|
return Colors.purple;
|
||||||
|
case advTypeSensor:
|
||||||
|
return Colors.green;
|
||||||
|
default:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatLastSeen(DateTime lastSeen) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(lastSeen);
|
||||||
|
|
||||||
|
if (diff.inMinutes < 1) return 'Just now';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||||
|
return '${lastSeen.month}/${lastSeen.day}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,292 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import 'channels_screen.dart';
|
||||||
|
import 'contacts_screen.dart';
|
||||||
|
import 'map_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
/// Main hub screen after connecting to a MeshCore device
|
||||||
|
class DeviceScreen extends StatefulWidget {
|
||||||
|
const DeviceScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeviceScreen> createState() => _DeviceScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceScreenState extends State<DeviceScreen> {
|
||||||
|
bool _showBatteryVoltage = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
// If disconnected, pop back to scanner
|
||||||
|
if (!connector.isConnected) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.popUntil(context, (route) => route.isFirst);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(connector.device?.platformName ?? 'MeshCore Device'),
|
||||||
|
centerTitle: true,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bluetooth_disabled),
|
||||||
|
tooltip: 'Disconnect',
|
||||||
|
onPressed: () => _disconnect(context, connector),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Connection status card
|
||||||
|
_buildStatusCard(connector, context),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Navigation grid
|
||||||
|
Expanded(
|
||||||
|
child: _buildNavigationGrid(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusCard(MeshCoreConnector connector, BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.bluetooth_connected, color: Colors.green, size: 32),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
connector.device?.platformName ?? 'Unknown Device',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
connector.device?.remoteId.toString() ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Connected',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildBatteryIndicator(connector, context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBatteryIndicator(MeshCoreConnector connector, BuildContext context) {
|
||||||
|
final percent = connector.batteryPercent;
|
||||||
|
final millivolts = connector.batteryMillivolts;
|
||||||
|
final percentLabel = percent != null ? '$percent%' : '--%';
|
||||||
|
final voltageLabel = millivolts == null
|
||||||
|
? '-- V'
|
||||||
|
: '${(millivolts / 1000.0).toStringAsFixed(2)} V';
|
||||||
|
final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel;
|
||||||
|
final icon = _batteryIcon(percent);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_showBatteryVoltage = !_showBatteryVoltage;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: Colors.grey[700]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
displayLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _batteryIcon(int? percent) {
|
||||||
|
if (percent == null) return Icons.battery_unknown;
|
||||||
|
if (percent <= 15) return Icons.battery_alert;
|
||||||
|
return Icons.battery_full;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavigationGrid(BuildContext context) {
|
||||||
|
final items = [
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
label: 'Contacts',
|
||||||
|
color: Colors.blue,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const ContactsScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.tag,
|
||||||
|
label: 'Channels',
|
||||||
|
color: Colors.green,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const ChannelsScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.map_outlined,
|
||||||
|
label: 'Map',
|
||||||
|
color: Colors.orange,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const MapScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.settings_outlined,
|
||||||
|
label: 'Settings',
|
||||||
|
color: Colors.grey,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return GridView.builder(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
childAspectRatio: 1.2,
|
||||||
|
),
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return _buildNavCard(item);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavCard(_NavItem item) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: item.onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
item.icon,
|
||||||
|
size: 48,
|
||||||
|
color: item.color,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
item.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disconnect(BuildContext context, MeshCoreConnector connector) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Disconnect'),
|
||||||
|
content: const Text('Are you sure you want to disconnect from this device?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Disconnect'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
await connector.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavItem {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
_NavItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,528 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../widgets/debug_frame_viewer.dart';
|
||||||
|
import '../services/repeater_command_service.dart';
|
||||||
|
|
||||||
|
class RepeaterCliScreen extends StatefulWidget {
|
||||||
|
final Contact repeater;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
const RepeaterCliScreen({
|
||||||
|
super.key,
|
||||||
|
required this.repeater,
|
||||||
|
required this.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RepeaterCliScreen> createState() => _RepeaterCliScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||||
|
final TextEditingController _commandController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final List<Map<String, String>> _commandHistory = [];
|
||||||
|
int _historyIndex = -1;
|
||||||
|
StreamSubscription<Uint8List>? _frameSubscription;
|
||||||
|
RepeaterCommandService? _commandService;
|
||||||
|
|
||||||
|
// Common commands for quick access
|
||||||
|
final List<Map<String, String>> _quickCommands = [
|
||||||
|
{'label': 'Get Name', 'command': 'get name'},
|
||||||
|
{'label': 'Get Radio', 'command': 'get radio'},
|
||||||
|
{'label': 'Get TX', 'command': 'get tx'},
|
||||||
|
{'label': 'Neighbors', 'command': 'neighbors'},
|
||||||
|
{'label': 'Version', 'command': 'ver'},
|
||||||
|
{'label': 'Advertise', 'command': 'advert'},
|
||||||
|
{'label': 'Clock', 'command': 'clock'},
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
_commandService = RepeaterCommandService(connector);
|
||||||
|
_setupMessageListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_frameSubscription?.cancel();
|
||||||
|
_commandService?.dispose();
|
||||||
|
_commandController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupMessageListener() {
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
|
||||||
|
// Listen for incoming text messages from the repeater
|
||||||
|
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||||
|
if (frame.isEmpty) return;
|
||||||
|
|
||||||
|
// Check if it's a text message response
|
||||||
|
if (frame[0] == respCodeContactMsgRecv ||
|
||||||
|
frame[0] == respCodeContactMsgRecvV3) {
|
||||||
|
_handleTextMessageResponse(frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTextMessageResponse(Uint8List frame) {
|
||||||
|
final parsed = parseContactMessageText(frame);
|
||||||
|
if (parsed == null) return;
|
||||||
|
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
|
||||||
|
|
||||||
|
// Notify command service of response (for retry handling)
|
||||||
|
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||||
|
|
||||||
|
// Note: The command service will handle the response via the Future
|
||||||
|
// We don't need to add it to history here anymore as _sendCommand will do it
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||||
|
final target = widget.repeater.publicKey;
|
||||||
|
if (target.length < 6 || prefix.length < 6) return false;
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
if (prefix[i] != target[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendCommand({bool showDebug = false}) async {
|
||||||
|
final command = _commandController.text.trim();
|
||||||
|
if (command.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_commandHistory.add({
|
||||||
|
'type': 'command',
|
||||||
|
'text': command,
|
||||||
|
'timestamp': DateTime.now().toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show debug info if requested
|
||||||
|
if (showDebug && mounted) {
|
||||||
|
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
||||||
|
DebugFrameViewer.showFrameDebug(context, frame, 'CLI Command Frame');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send CLI command to repeater with retry
|
||||||
|
try {
|
||||||
|
if (_commandService != null) {
|
||||||
|
final response = await _commandService!.sendCommand(
|
||||||
|
widget.repeater,
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_commandHistory.add({
|
||||||
|
'type': 'response',
|
||||||
|
'text': response,
|
||||||
|
'timestamp': DateTime.now().toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_commandHistory.add({
|
||||||
|
'type': 'response',
|
||||||
|
'text': 'Error: $e',
|
||||||
|
'timestamp': DateTime.now().toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_commandController.clear();
|
||||||
|
_historyIndex = -1;
|
||||||
|
|
||||||
|
// Auto-scroll to bottom
|
||||||
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _useQuickCommand(String command) {
|
||||||
|
_commandController.text = command;
|
||||||
|
_sendCommand();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateHistory(bool up) {
|
||||||
|
final commands = _commandHistory
|
||||||
|
.where((entry) => entry['type'] == 'command')
|
||||||
|
.toList()
|
||||||
|
.reversed
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (commands.isEmpty) return;
|
||||||
|
|
||||||
|
if (up) {
|
||||||
|
if (_historyIndex < commands.length - 1) {
|
||||||
|
_historyIndex++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_historyIndex > 0) {
|
||||||
|
_historyIndex--;
|
||||||
|
} else {
|
||||||
|
_historyIndex = -1;
|
||||||
|
_commandController.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_historyIndex >= 0 && _historyIndex < commands.length) {
|
||||||
|
_commandController.text = commands[_historyIndex]['text'] ?? '';
|
||||||
|
_commandController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: _commandController.text.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearHistory() {
|
||||||
|
setState(() {
|
||||||
|
_commandHistory.clear();
|
||||||
|
_historyIndex = -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Repeater CLI'),
|
||||||
|
Text(
|
||||||
|
widget.repeater.name,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bug_report),
|
||||||
|
tooltip: 'Debug Next Command',
|
||||||
|
onPressed: () {
|
||||||
|
// Set a flag or just send next command with debug
|
||||||
|
if (_commandController.text.trim().isNotEmpty) {
|
||||||
|
_sendCommand(showDebug: true);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Enter a command first')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.help_outline),
|
||||||
|
tooltip: 'Command Help',
|
||||||
|
onPressed: () => _showCommandHelp(context),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.clear_all),
|
||||||
|
tooltip: 'Clear History',
|
||||||
|
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
_buildQuickCommandsBar(),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: _commandHistory.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: _buildCommandHistory(),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_buildCommandInput(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuickCommandsBar() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: _quickCommands.map((cmd) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ActionChip(
|
||||||
|
label: Text(cmd['label']!),
|
||||||
|
onPressed: () => _useQuickCommand(cmd['command']!),
|
||||||
|
avatar: const Icon(Icons.play_arrow, size: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No commands sent yet',
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Type a command below or use quick commands',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCommandHistory() {
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _commandHistory.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = _commandHistory[index];
|
||||||
|
final isCommand = entry['type'] == 'command';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCommand
|
||||||
|
? Theme.of(context).colorScheme.primaryContainer
|
||||||
|
: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isCommand ? Icons.chevron_right : Icons.arrow_back,
|
||||||
|
size: 16,
|
||||||
|
color: isCommand
|
||||||
|
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||||
|
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SelectableText(
|
||||||
|
entry['text']!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
color: isCommand
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCommandInput() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_upward, size: 20),
|
||||||
|
tooltip: 'Previous command',
|
||||||
|
onPressed: () => _navigateHistory(true),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_downward, size: 20),
|
||||||
|
tooltip: 'Next command',
|
||||||
|
onPressed: () => _navigateHistory(false),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _commandController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Enter command...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
prefixText: '> ',
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontFamily: 'monospace'),
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _sendCommand(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filled(
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
onPressed: _sendCommand,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCommandHelp(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Commands List'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'NOTE: for the various "set ..." commands, there is also a "get ..." command.',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildHelpSection('General', [
|
||||||
|
'advert - Sends an advertisement packet',
|
||||||
|
"reboot - Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
|
||||||
|
"clock - Displays current time per device's clock.",
|
||||||
|
'password {new-password} - Sets a new admin password for the device.',
|
||||||
|
'ver - Shows the device version and firmware build date.',
|
||||||
|
'clear stats - Resets various stats counters to zero.',
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildHelpSection('Settings', [
|
||||||
|
'set af {air-time-factor} - Sets the air-time-factor.',
|
||||||
|
'set tx {tx-power-dbm} - Sets LoRa transmit power in dBm. (reboot to apply)',
|
||||||
|
'set repeat {on|off} - Enables or disables the repeater role for this node.',
|
||||||
|
"set allow.read.only {on|off} - (Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
|
||||||
|
'set flood.max {max-hops} - Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
|
||||||
|
'set int.thresh {db} - Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
|
||||||
|
'set agc.reset.interval {seconds} - Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
|
||||||
|
"set multi.acks {0|1} - Enables or disables the 'double ACKs' feature.",
|
||||||
|
'set advert.interval {minutes} - Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
|
||||||
|
'set flood.advert.interval {hours} - Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
|
||||||
|
'set guest.password {guess-password} - Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
|
||||||
|
'set name {name} - Sets the advertisement name.',
|
||||||
|
'set lat {latitude} - Sets the advertisement map latitude. (decimal degrees)',
|
||||||
|
'set lon {longitude} - Sets the advertisement map longitude. (decimal degrees)',
|
||||||
|
'set radio {freq},{bw},{sf},{cr} - Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
|
||||||
|
'set rxdelay {base} - Sets (experimental) base (must be > 1 for effect) for applying slight delay to received packets, based on signal strength/score. Set to 0 to disable.',
|
||||||
|
'set txdelay {factor} - Sets a factor multiplied with time-on-air for a flood-mode packet and with a randomized slot system, to delay its forwarding. (to decrease likelihood of collisions)',
|
||||||
|
'set direct.txdelay {factor} - Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
|
||||||
|
'set bridge.enabled {on|off} - Enable/Disable bridge.',
|
||||||
|
'set bridge.delay {0-10000} - Set delay before retransmitting packets.',
|
||||||
|
'set bridge.source {rx|tx} - Choose wether the bridge will retransmit received packets or transmitted packets.',
|
||||||
|
'set bridge.baud {speed} - Set serial link baudrate for rs232 bridges.',
|
||||||
|
'set bridge.secret {shared-secret} - Set bridge secret for espnow bridges.',
|
||||||
|
'set adc.multiplier {factor} - Sets custom factor to adjust reported battery voltage (only supported on select boards).',
|
||||||
|
'tempradio {freq},{bw},{sf},{cr},{minutes} - Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
|
||||||
|
'setperm {pubkey-hex} {permissions} - Modifies the ACL. Removes matching entry (by pubkey prefix) if "permissions" is zero. Adds new entry if pubkey-hex is full length and is not currently in ACL. Updates entry by matching pubkey prefix. Permission bits vary per firmware role, but low 2 bits are: 0 (Guest), 1 (Read only), 2 (Read write), 3 (Admin)',
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildHelpSection('Bridge', [
|
||||||
|
'get bridge.type - Gets bridge type none, rs232, espnow',
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildHelpSection('Logging', [
|
||||||
|
'log start - Starts packet logging to file system.',
|
||||||
|
'log stop - Stops packet logging to file system.',
|
||||||
|
'log erase - Erases the packet logs from file system.',
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildHelpSection('Neighbors (Repeater only)', [
|
||||||
|
'neighbors - Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
|
||||||
|
'neighbor.remove {pubkey-prefix} - Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildHelpSection('Region Management (Repeater only)', [
|
||||||
|
'region commands have been introduced to manage region definitions and permissions.',
|
||||||
|
'region - (serial only) Lists all defined regions and current flood permissions.',
|
||||||
|
'region load - NOTE: this is a special multi-command invocation. Each subsequent command is a region name (indented with spaces to indicate parent hierarchy, with one space at minimum). Terminated by sending a blank line/command.',
|
||||||
|
"region get {* | name-prefix} - Searches for region with given name prefix (or '*' for the global scope). Replies with \"-> {region-name} ({parent-name}) {'F'}\"",
|
||||||
|
'region put {name} {* | parent-name-prefix} - Adds or updates a region definition with given name.',
|
||||||
|
'region remove {name} - Removes a region definition with given name. (must match exactly, and have no child regions)',
|
||||||
|
"region allowf {* | name-prefix} - Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
|
||||||
|
"region denyf {* | name-prefix} - Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
|
||||||
|
"region home - Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
|
||||||
|
"region home {* | name-prefix} - Sets the 'home' region.",
|
||||||
|
'region save - Persists the region list/map to storage.',
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildHelpSection('GPS Management', [
|
||||||
|
'gps command has been introduced to manage location related topics.',
|
||||||
|
'gps - Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
|
||||||
|
'gps {on|off} - Toggles gps power state.',
|
||||||
|
'gps sync - Syncs node time with gps clock.',
|
||||||
|
"gps setloc - Sets node's position to gps coordinates and save preferences.",
|
||||||
|
'gps advert - Gives location advert configuration of the node:',
|
||||||
|
"none: don't include location in adverts",
|
||||||
|
'share: share gps location (from SensorManager)',
|
||||||
|
'prefs: advert the location stored in preferences',
|
||||||
|
'gps advert {none|share|prefs} - Sets location advert configuration.',
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHelpSection(String title, List<String> commands) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...commands.map((cmd) => Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8, bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
'• $cmd',
|
||||||
|
style: const TextStyle(fontSize: 13, fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import 'repeater_status_screen.dart';
|
||||||
|
import 'repeater_cli_screen.dart';
|
||||||
|
import 'repeater_settings_screen.dart';
|
||||||
|
|
||||||
|
class RepeaterHubScreen extends StatelessWidget {
|
||||||
|
final Contact repeater;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
const RepeaterHubScreen({
|
||||||
|
super.key,
|
||||||
|
required this.repeater,
|
||||||
|
required this.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Repeater Management'),
|
||||||
|
Text(
|
||||||
|
repeater.name,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
centerTitle: false,
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Repeater info card
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 40,
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
child: const Icon(Icons.cell_tower, size: 40, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
repeater.name,
|
||||||
|
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
repeater.pathLabel,
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
if (repeater.hasLocation) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.location_on, size: 14, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'Management Tools',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Status button
|
||||||
|
_buildManagementCard(
|
||||||
|
context,
|
||||||
|
icon: Icons.analytics,
|
||||||
|
title: 'Status',
|
||||||
|
subtitle: 'View repeater status, stats, and neighbors',
|
||||||
|
color: Colors.blue,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => RepeaterStatusScreen(
|
||||||
|
repeater: repeater,
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// CLI button
|
||||||
|
_buildManagementCard(
|
||||||
|
context,
|
||||||
|
icon: Icons.terminal,
|
||||||
|
title: 'CLI',
|
||||||
|
subtitle: 'Send commands to the repeater',
|
||||||
|
color: Colors.green,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => RepeaterCliScreen(
|
||||||
|
repeater: repeater,
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Settings button
|
||||||
|
_buildManagementCard(
|
||||||
|
context,
|
||||||
|
icon: Icons.settings,
|
||||||
|
title: 'Settings',
|
||||||
|
subtitle: 'Configure repeater parameters',
|
||||||
|
color: Colors.orange,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => RepeaterSettingsScreen(
|
||||||
|
repeater: repeater,
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildManagementCard(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required Color color,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 32),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right, color: Colors.grey[400]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,508 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../services/repeater_command_service.dart';
|
||||||
|
|
||||||
|
class RepeaterStatusScreen extends StatefulWidget {
|
||||||
|
final Contact repeater;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
const RepeaterStatusScreen({
|
||||||
|
super.key,
|
||||||
|
required this.repeater,
|
||||||
|
required this.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RepeaterStatusScreen> createState() => _RepeaterStatusScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
StreamSubscription<Uint8List>? _frameSubscription;
|
||||||
|
RepeaterCommandService? _commandService;
|
||||||
|
Timer? _statusTimeout;
|
||||||
|
DateTime? _statusRequestedAt;
|
||||||
|
int? _batteryMv;
|
||||||
|
int? _uptimeSecs;
|
||||||
|
int? _queueLen;
|
||||||
|
int? _debugFlags;
|
||||||
|
int? _lastRssi;
|
||||||
|
double? _lastSnr;
|
||||||
|
int? _noiseFloor;
|
||||||
|
int? _txAirSecs;
|
||||||
|
int? _rxAirSecs;
|
||||||
|
int? _packetsSent;
|
||||||
|
int? _packetsRecv;
|
||||||
|
int? _floodTx;
|
||||||
|
int? _directTx;
|
||||||
|
int? _floodRx;
|
||||||
|
int? _directRx;
|
||||||
|
int? _dupFlood;
|
||||||
|
int? _dupDirect;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
_commandService = RepeaterCommandService(connector);
|
||||||
|
_setupMessageListener();
|
||||||
|
_loadStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_frameSubscription?.cancel();
|
||||||
|
_commandService?.dispose();
|
||||||
|
_statusTimeout?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupMessageListener() {
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
|
||||||
|
// Listen for incoming text messages from the repeater
|
||||||
|
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||||
|
if (frame.isEmpty) return;
|
||||||
|
|
||||||
|
// Check if it's a text message response
|
||||||
|
if (frame[0] == pushCodeStatusResponse) {
|
||||||
|
_handleStatusResponse(frame);
|
||||||
|
} else if (frame[0] == respCodeContactMsgRecv ||
|
||||||
|
frame[0] == respCodeContactMsgRecvV3) {
|
||||||
|
_handleTextMessageResponse(frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTextMessageResponse(Uint8List frame) {
|
||||||
|
final parsed = parseContactMessageText(frame);
|
||||||
|
if (parsed == null) return;
|
||||||
|
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
|
||||||
|
|
||||||
|
// Notify command service of response (for retry handling)
|
||||||
|
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||||
|
|
||||||
|
// Parse status responses
|
||||||
|
_parseStatusResponse(parsed.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleStatusResponse(Uint8List frame) {
|
||||||
|
if (frame.length < 8) return;
|
||||||
|
final prefix = frame.sublist(2, 8);
|
||||||
|
if (!_matchesRepeaterPrefix(prefix)) return;
|
||||||
|
|
||||||
|
const payloadOffset = 8;
|
||||||
|
const statsSize = 52;
|
||||||
|
if (frame.length < payloadOffset + statsSize) return;
|
||||||
|
|
||||||
|
final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize);
|
||||||
|
int offset = 0;
|
||||||
|
|
||||||
|
final batteryMv = data.getUint16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
final queueLen = data.getUint16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
final noiseFloor = data.getInt16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
final lastRssi = data.getInt16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
final packetsRecv = data.getUint32(offset, Endian.little);
|
||||||
|
offset += 4;
|
||||||
|
final packetsSent = data.getUint32(offset, Endian.little);
|
||||||
|
offset += 4;
|
||||||
|
final txAirSecs = data.getUint32(offset, Endian.little);
|
||||||
|
offset += 4;
|
||||||
|
final uptimeSecs = data.getUint32(offset, Endian.little);
|
||||||
|
offset += 4;
|
||||||
|
final floodTx = data.getUint32(offset, Endian.little);
|
||||||
|
offset += 4;
|
||||||
|
final directTx = data.getUint32(offset, Endian.little);
|
||||||
|
offset += 4;
|
||||||
|
final floodRx = data.getUint32(offset, Endian.little);
|
||||||
|
offset += 4;
|
||||||
|
final directRx = data.getUint32(offset, Endian.little);
|
||||||
|
offset += 4;
|
||||||
|
final errEvents = data.getUint16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
final lastSnrRaw = data.getInt16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
final directDups = data.getUint16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
final floodDups = data.getUint16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
final rxAirSecs = data.getUint32(offset, Endian.little);
|
||||||
|
|
||||||
|
_statusTimeout?.cancel();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_batteryMv = batteryMv;
|
||||||
|
_queueLen = queueLen;
|
||||||
|
_noiseFloor = noiseFloor;
|
||||||
|
_lastRssi = lastRssi;
|
||||||
|
_packetsRecv = packetsRecv;
|
||||||
|
_packetsSent = packetsSent;
|
||||||
|
_txAirSecs = txAirSecs;
|
||||||
|
_rxAirSecs = rxAirSecs;
|
||||||
|
_uptimeSecs = uptimeSecs;
|
||||||
|
_floodTx = floodTx;
|
||||||
|
_directTx = directTx;
|
||||||
|
_floodRx = floodRx;
|
||||||
|
_directRx = directRx;
|
||||||
|
_debugFlags = errEvents;
|
||||||
|
_lastSnr = lastSnrRaw / 4.0;
|
||||||
|
_dupDirect = directDups;
|
||||||
|
_dupFlood = floodDups;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||||
|
final target = widget.repeater.publicKey;
|
||||||
|
if (target.length < 6 || prefix.length < 6) return false;
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
if (prefix[i] != target[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseStatusResponse(String response) {
|
||||||
|
final trimmed = response.trim();
|
||||||
|
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(trimmed) as Map<String, dynamic>;
|
||||||
|
if (data.containsKey('battery_mv')) {
|
||||||
|
_batteryMv = _asInt(data['battery_mv']);
|
||||||
|
_uptimeSecs = _asInt(data['uptime_secs']);
|
||||||
|
_queueLen = _asInt(data['queue_len']);
|
||||||
|
_debugFlags = _asInt(data['errors']);
|
||||||
|
} else if (data.containsKey('noise_floor')) {
|
||||||
|
_noiseFloor = _asInt(data['noise_floor']);
|
||||||
|
_lastRssi = _asInt(data['last_rssi']);
|
||||||
|
_lastSnr = _asDouble(data['last_snr']);
|
||||||
|
_txAirSecs = _asInt(data['tx_air_secs']);
|
||||||
|
_rxAirSecs = _asInt(data['rx_air_secs']);
|
||||||
|
} else if (data.containsKey('recv') || data.containsKey('sent')) {
|
||||||
|
_packetsRecv = _asInt(data['recv']);
|
||||||
|
_packetsSent = _asInt(data['sent']);
|
||||||
|
_floodTx = _asInt(data['flood_tx']);
|
||||||
|
_directTx = _asInt(data['direct_tx']);
|
||||||
|
_floodRx = _asInt(data['flood_rx']);
|
||||||
|
_directRx = _asInt(data['direct_rx']);
|
||||||
|
_dupFlood = _asInt(data['dup_flood']);
|
||||||
|
_dupDirect = _asInt(data['dup_direct']);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore parse failures for non-JSON responses.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStatus() async {
|
||||||
|
if (_commandService == null) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_statusRequestedAt = DateTime.now();
|
||||||
|
_batteryMv = null;
|
||||||
|
_uptimeSecs = null;
|
||||||
|
_queueLen = null;
|
||||||
|
_debugFlags = null;
|
||||||
|
_lastRssi = null;
|
||||||
|
_lastSnr = null;
|
||||||
|
_noiseFloor = null;
|
||||||
|
_txAirSecs = null;
|
||||||
|
_rxAirSecs = null;
|
||||||
|
_packetsSent = null;
|
||||||
|
_packetsRecv = null;
|
||||||
|
_floodTx = null;
|
||||||
|
_directTx = null;
|
||||||
|
_floodRx = null;
|
||||||
|
_directRx = null;
|
||||||
|
_dupFlood = null;
|
||||||
|
_dupDirect = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
final frame = buildSendStatusRequestFrame(widget.repeater.publicKey);
|
||||||
|
await connector.sendFrame(frame);
|
||||||
|
|
||||||
|
_statusTimeout?.cancel();
|
||||||
|
_statusTimeout = Timer(const Duration(seconds: 12), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Status request timed out.'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Error loading status: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Repeater Status'),
|
||||||
|
Text(
|
||||||
|
widget.repeater.name,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
onPressed: _isLoading ? null : _loadStatus,
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _loadStatus,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildSystemInfoCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildRadioStatsCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPacketStatsCard(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSystemInfoCard() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: Theme.of(context).primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'System Information',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_buildInfoRow('Battery', _batteryText()),
|
||||||
|
_buildInfoRow('Clock (at login)', _clockText()),
|
||||||
|
_buildInfoRow('Uptime', _formatDuration(_uptimeSecs)),
|
||||||
|
_buildInfoRow('Queue Length', _formatValue(_queueLen)),
|
||||||
|
_buildInfoRow('Debug Flags', _formatValue(_debugFlags)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRadioStatsCard() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.radio, color: Theme.of(context).primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Radio Statistics',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_buildInfoRow('Last RSSI', _formatValue(_lastRssi, suffix: ' dB')),
|
||||||
|
_buildInfoRow('Last SNR', _formatSnr(_lastSnr)),
|
||||||
|
_buildInfoRow('Noise Floor', _formatValue(_noiseFloor, suffix: ' dB')),
|
||||||
|
_buildInfoRow('TX Airtime', _formatDuration(_txAirSecs)),
|
||||||
|
_buildInfoRow('RX Airtime', _formatDuration(_rxAirSecs)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPacketStatsCard() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.analytics, color: Theme.of(context).primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Packet Statistics',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_buildInfoRow('Sent', _packetTxText()),
|
||||||
|
_buildInfoRow('Received', _packetRxText()),
|
||||||
|
_buildInfoRow('Duplicates', _duplicateText()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 130,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _asInt(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is double) return value.round();
|
||||||
|
return int.tryParse(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _asDouble(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is double) return value;
|
||||||
|
if (value is int) return value.toDouble();
|
||||||
|
return double.tryParse(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
String _batteryText() {
|
||||||
|
if (_batteryMv == null) return '—';
|
||||||
|
final percent = _batteryPercentFromMv(_batteryMv!);
|
||||||
|
final volts = (_batteryMv! / 1000.0).toStringAsFixed(2);
|
||||||
|
return '$percent% / ${volts}V';
|
||||||
|
}
|
||||||
|
|
||||||
|
int _batteryPercentFromMv(int millivolts) {
|
||||||
|
const minMv = 3000;
|
||||||
|
const maxMv = 4200;
|
||||||
|
if (millivolts <= minMv) return 0;
|
||||||
|
if (millivolts >= maxMv) return 100;
|
||||||
|
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _clockText() {
|
||||||
|
if (_statusRequestedAt == null) return '—';
|
||||||
|
final dt = _statusRequestedAt!;
|
||||||
|
final date = '${dt.day}/${dt.month}/${dt.year}';
|
||||||
|
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||||
|
return '$date $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(int? seconds) {
|
||||||
|
if (seconds == null) return '—';
|
||||||
|
final days = seconds ~/ 86400;
|
||||||
|
final hours = (seconds % 86400) ~/ 3600;
|
||||||
|
final minutes = (seconds % 3600) ~/ 60;
|
||||||
|
final secs = seconds % 60;
|
||||||
|
return '$days days ${hours}h ${minutes}m ${secs}s';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _packetTxText() {
|
||||||
|
if (_packetsSent == null) return '—';
|
||||||
|
final flood = _formatValue(_floodTx);
|
||||||
|
final direct = _formatValue(_directTx);
|
||||||
|
return 'Total: $_packetsSent, Flood: $flood, Direct: $direct';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _packetRxText() {
|
||||||
|
if (_packetsRecv == null) return '—';
|
||||||
|
final flood = _formatValue(_floodRx);
|
||||||
|
final direct = _formatValue(_directRx);
|
||||||
|
return 'Total: $_packetsRecv, Flood: $flood, Direct: $direct';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _duplicateText() {
|
||||||
|
if (_dupFlood != null || _dupDirect != null) {
|
||||||
|
final flood = _formatValue(_dupFlood);
|
||||||
|
final direct = _formatValue(_dupDirect);
|
||||||
|
return 'Flood: $flood, Direct: $direct';
|
||||||
|
}
|
||||||
|
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '—';
|
||||||
|
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
|
||||||
|
if (dupTotal < 0) return '—';
|
||||||
|
return 'Total: $dupTotal';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatValue(num? value, {String? suffix}) {
|
||||||
|
if (value == null) return '—';
|
||||||
|
return suffix == null ? value.toString() : '$value$suffix';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSnr(double? snr) {
|
||||||
|
if (snr == null) return '—';
|
||||||
|
return snr.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../widgets/device_tile.dart';
|
||||||
|
import 'device_screen.dart';
|
||||||
|
|
||||||
|
/// Screen for scanning and connecting to MeshCore devices
|
||||||
|
class ScannerScreen extends StatelessWidget {
|
||||||
|
const ScannerScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('MeshCore Open'),
|
||||||
|
centerTitle: true,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
),
|
||||||
|
body: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Status bar
|
||||||
|
_buildStatusBar(context, connector),
|
||||||
|
|
||||||
|
// Device list
|
||||||
|
Expanded(
|
||||||
|
child: _buildDeviceList(context, connector),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
final isScanning = connector.state == MeshCoreConnectionState.scanning;
|
||||||
|
|
||||||
|
return FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
if (isScanning) {
|
||||||
|
connector.stopScan();
|
||||||
|
} else {
|
||||||
|
connector.startScan();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: isScanning
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.bluetooth_searching),
|
||||||
|
label: Text(isScanning ? 'Stop' : 'Scan'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
String statusText;
|
||||||
|
Color statusColor;
|
||||||
|
|
||||||
|
switch (connector.state) {
|
||||||
|
case MeshCoreConnectionState.scanning:
|
||||||
|
statusText = 'Scanning for devices...';
|
||||||
|
statusColor = Colors.blue;
|
||||||
|
break;
|
||||||
|
case MeshCoreConnectionState.connecting:
|
||||||
|
statusText = 'Connecting...';
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
break;
|
||||||
|
case MeshCoreConnectionState.connected:
|
||||||
|
statusText = 'Connected to ${connector.device?.platformName}';
|
||||||
|
statusColor = Colors.green;
|
||||||
|
break;
|
||||||
|
case MeshCoreConnectionState.disconnecting:
|
||||||
|
statusText = 'Disconnecting...';
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
break;
|
||||||
|
case MeshCoreConnectionState.disconnected:
|
||||||
|
statusText = 'Not connected';
|
||||||
|
statusColor = Colors.grey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: statusColor.withValues(alpha: 0.1),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.circle, size: 12, color: statusColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
statusText,
|
||||||
|
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
if (connector.scanResults.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.bluetooth,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
connector.state == MeshCoreConnectionState.scanning
|
||||||
|
? 'Searching for MeshCore devices...'
|
||||||
|
: 'Tap Scan to find MeshCore devices',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
itemCount: connector.scanResults.length,
|
||||||
|
separatorBuilder: (context, index) => const Divider(),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final result = connector.scanResults[index];
|
||||||
|
return DeviceTile(
|
||||||
|
scanResult: result,
|
||||||
|
onTap: () => _connectToDevice(context, connector, result),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connectToDevice(
|
||||||
|
BuildContext context,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
ScanResult result,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await connector.connect(result.device);
|
||||||
|
|
||||||
|
if (context.mounted && connector.isConnected) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const DeviceScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Connection failed: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,693 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../models/radio_settings.dart';
|
||||||
|
import '../services/app_settings_service.dart';
|
||||||
|
import 'app_settings_screen.dart';
|
||||||
|
import 'ble_debug_log_screen.dart';
|
||||||
|
|
||||||
|
class SettingsScreen extends StatelessWidget {
|
||||||
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Settings'),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildDeviceInfoCard(connector),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildAppSettingsCard(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildNodeSettingsCard(context, connector),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildActionsCard(context, connector),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDebugCard(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildAboutCard(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDeviceInfoCard(MeshCoreConnector connector) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Device Info',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildInfoRow('Name', connector.device?.platformName ?? 'Unknown'),
|
||||||
|
_buildInfoRow('ID', connector.device?.remoteId.toString() ?? 'Unknown'),
|
||||||
|
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
|
||||||
|
if (connector.selfName != null)
|
||||||
|
_buildInfoRow('Node Name', connector.selfName!),
|
||||||
|
if (connector.selfPublicKey != null)
|
||||||
|
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppSettingsCard(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.settings_outlined),
|
||||||
|
title: const Text('App Settings'),
|
||||||
|
subtitle: const Text('Notifications, messaging, and map preferences'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const AppSettingsScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNodeSettingsCard(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Node Settings',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_outline),
|
||||||
|
title: const Text('Node Name'),
|
||||||
|
subtitle: Text(connector.selfName ?? 'Not set'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _editNodeName(context, connector),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.radio),
|
||||||
|
title: const Text('Radio Settings'),
|
||||||
|
subtitle: const Text('Frequency, power, spreading factor'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showRadioSettings(context, connector),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.location_on_outlined),
|
||||||
|
title: const Text('Location'),
|
||||||
|
subtitle: const Text('GPS coordinates'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _editLocation(context, connector),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.visibility_off_outlined),
|
||||||
|
title: const Text('Privacy Mode'),
|
||||||
|
subtitle: const Text('Hide name/location in advertisements'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _togglePrivacy(context, connector),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Actions',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.cell_tower),
|
||||||
|
title: const Text('Send Advertisement'),
|
||||||
|
subtitle: const Text('Broadcast presence now'),
|
||||||
|
onTap: () => _sendAdvert(context, connector),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.sync),
|
||||||
|
title: const Text('Sync Time'),
|
||||||
|
subtitle: const Text('Set device clock to phone time'),
|
||||||
|
onTap: () => _syncTime(context, connector),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.refresh),
|
||||||
|
title: const Text('Refresh Contacts'),
|
||||||
|
subtitle: const Text('Reload contact list from device'),
|
||||||
|
onTap: () => connector.getContacts(),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.restart_alt, color: Colors.orange),
|
||||||
|
title: const Text('Reboot Device'),
|
||||||
|
subtitle: const Text('Restart the MeshCore device'),
|
||||||
|
onTap: () => _confirmReboot(context, connector),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAboutCard(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('About'),
|
||||||
|
subtitle: const Text('MeshCore Open v0.1.0'),
|
||||||
|
onTap: () => _showAbout(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDebugCard(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.bug_report_outlined),
|
||||||
|
title: const Text('BLE Debug Log'),
|
||||||
|
subtitle: const Text('Commands, responses, and status'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final controller = TextEditingController(text: connector.selfName ?? '');
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Node Name'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Enter node name',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLength: 31,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await connector.sendCliCommand('set name ${controller.text}');
|
||||||
|
await connector.refreshDeviceInfo();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Name updated')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRadioSettings(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _RadioSettingsDialog(connector: connector),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _editLocation(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final latController = TextEditingController();
|
||||||
|
final lonController = TextEditingController();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Location'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: latController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Latitude',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: lonController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Longitude',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
var updated = false;
|
||||||
|
if (latController.text.isNotEmpty) {
|
||||||
|
await connector.sendCliCommand('set lat ${latController.text}');
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (lonController.text.isNotEmpty) {
|
||||||
|
await connector.sendCliCommand('set lon ${lonController.text}');
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
await connector.refreshDeviceInfo();
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Location updated')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Privacy Mode'),
|
||||||
|
content: const Text('Toggle privacy mode to hide your name and location in advertisements.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await connector.sendCliCommand('set privacy on');
|
||||||
|
await connector.refreshDeviceInfo();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Privacy mode enabled')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Enable'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await connector.sendCliCommand('set privacy off');
|
||||||
|
await connector.refreshDeviceInfo();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Privacy mode disabled')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Disable'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
connector.sendCliCommand('advert');
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Advertisement sent')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncTime(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
connector.syncTime();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Time synchronized')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Reboot Device'),
|
||||||
|
content: const Text('Are you sure you want to reboot the device? You will be disconnected.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
connector.sendCliCommand('reboot');
|
||||||
|
},
|
||||||
|
child: const Text('Reboot', style: TextStyle(color: Colors.orange)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAbout(BuildContext context) {
|
||||||
|
showAboutDialog(
|
||||||
|
context: context,
|
||||||
|
applicationName: 'MeshCore Open',
|
||||||
|
applicationVersion: '0.1.0',
|
||||||
|
applicationLegalese: '2024 MeshCore Open Source Project',
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'An open-source Flutter client for MeshCore LoRa mesh networking devices.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RadioSettingsDialog extends StatefulWidget {
|
||||||
|
final MeshCoreConnector connector;
|
||||||
|
|
||||||
|
const _RadioSettingsDialog({required this.connector});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_RadioSettingsDialog> createState() => _RadioSettingsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||||
|
final _frequencyController = TextEditingController();
|
||||||
|
LoRaBandwidth _bandwidth = LoRaBandwidth.bw125;
|
||||||
|
LoRaSpreadingFactor _spreadingFactor = LoRaSpreadingFactor.sf7;
|
||||||
|
LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5;
|
||||||
|
final _txPowerController = TextEditingController(text: '20');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Populate with current settings if available
|
||||||
|
if (widget.connector.currentFreqHz != null) {
|
||||||
|
_frequencyController.text = (widget.connector.currentFreqHz! / 1000.0).toStringAsFixed(3);
|
||||||
|
} else {
|
||||||
|
_frequencyController.text = '915.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.connector.currentBwHz != null) {
|
||||||
|
// Find matching bandwidth enum
|
||||||
|
final bwValue = widget.connector.currentBwHz!;
|
||||||
|
for (var bw in LoRaBandwidth.values) {
|
||||||
|
if (bw.hz == bwValue) {
|
||||||
|
_bandwidth = bw;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.connector.currentSf != null) {
|
||||||
|
// Find matching spreading factor enum
|
||||||
|
final sfValue = widget.connector.currentSf!;
|
||||||
|
for (var sf in LoRaSpreadingFactor.values) {
|
||||||
|
if (sf.value == sfValue) {
|
||||||
|
_spreadingFactor = sf;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.connector.currentCr != null) {
|
||||||
|
// Find matching coding rate enum
|
||||||
|
final crValue = _toUiCodingRate(widget.connector.currentCr!);
|
||||||
|
for (var cr in LoRaCodingRate.values) {
|
||||||
|
if (cr.value == crValue) {
|
||||||
|
_codingRate = cr;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.connector.currentTxPower != null) {
|
||||||
|
_txPowerController.text = widget.connector.currentTxPower.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_frequencyController.dispose();
|
||||||
|
_txPowerController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyPreset(RadioSettings preset) {
|
||||||
|
setState(() {
|
||||||
|
_frequencyController.text = preset.frequencyMHz.toString();
|
||||||
|
_bandwidth = preset.bandwidth;
|
||||||
|
_spreadingFactor = preset.spreadingFactor;
|
||||||
|
_codingRate = preset.codingRate;
|
||||||
|
_txPowerController.text = preset.txPowerDbm.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveSettings() async {
|
||||||
|
final freqMHz = double.tryParse(_frequencyController.text);
|
||||||
|
final txPower = int.tryParse(_txPowerController.text);
|
||||||
|
|
||||||
|
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Invalid frequency (300-2500 MHz)')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (txPower == null || txPower < 0 || txPower > 22) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Invalid TX power (0-22 dBm)')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final freqHz = (freqMHz * 1000).round();
|
||||||
|
final bwHz = _bandwidth.hz;
|
||||||
|
final sf = _spreadingFactor.value;
|
||||||
|
final cr = _toDeviceCodingRate(_codingRate.value, widget.connector.currentCr);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.connector.sendFrame(buildSetRadioParamsFrame(freqHz, bwHz, sf, cr));
|
||||||
|
await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower));
|
||||||
|
await widget.connector.refreshDeviceInfo();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Radio settings updated')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _toUiCodingRate(int deviceCr) {
|
||||||
|
return deviceCr <= 4 ? deviceCr + 4 : deviceCr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _toDeviceCodingRate(int uiCr, int? deviceCr) {
|
||||||
|
if (deviceCr != null && deviceCr <= 4) {
|
||||||
|
return uiCr - 4;
|
||||||
|
}
|
||||||
|
return uiCr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Radio Settings'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Presets', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
_PresetChip(
|
||||||
|
label: '915 MHz',
|
||||||
|
onTap: () => _applyPreset(RadioSettings.preset915MHz),
|
||||||
|
),
|
||||||
|
_PresetChip(
|
||||||
|
label: '868 MHz',
|
||||||
|
onTap: () => _applyPreset(RadioSettings.preset868MHz),
|
||||||
|
),
|
||||||
|
_PresetChip(
|
||||||
|
label: '433 MHz',
|
||||||
|
onTap: () => _applyPreset(RadioSettings.preset433MHz),
|
||||||
|
),
|
||||||
|
_PresetChip(
|
||||||
|
label: 'Long Range',
|
||||||
|
onTap: () => _applyPreset(RadioSettings.presetLongRange),
|
||||||
|
),
|
||||||
|
_PresetChip(
|
||||||
|
label: 'Fast Speed',
|
||||||
|
onTap: () => _applyPreset(RadioSettings.presetFastSpeed),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
controller: _frequencyController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Frequency (MHz)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: '300.0 - 2500.0',
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<LoRaBandwidth>(
|
||||||
|
value: _bandwidth,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Bandwidth',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: LoRaBandwidth.values
|
||||||
|
.map((bw) => DropdownMenuItem(
|
||||||
|
value: bw,
|
||||||
|
child: Text(bw.label),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) setState(() => _bandwidth = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<LoRaSpreadingFactor>(
|
||||||
|
value: _spreadingFactor,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Spreading Factor',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: LoRaSpreadingFactor.values
|
||||||
|
.map((sf) => DropdownMenuItem(
|
||||||
|
value: sf,
|
||||||
|
child: Text(sf.label),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) setState(() => _spreadingFactor = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<LoRaCodingRate>(
|
||||||
|
value: _codingRate,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Coding Rate',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: LoRaCodingRate.values
|
||||||
|
.map((cr) => DropdownMenuItem(
|
||||||
|
value: cr,
|
||||||
|
child: Text(cr.label),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) setState(() => _codingRate = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _txPowerController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'TX Power (dBm)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: '0 - 22',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _saveSettings,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PresetChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _PresetChip({required this.label, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(label),
|
||||||
|
onPressed: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../models/app_settings.dart';
|
||||||
|
|
||||||
|
class AppSettingsService extends ChangeNotifier {
|
||||||
|
static const String _settingsKey = 'app_settings';
|
||||||
|
|
||||||
|
AppSettings _settings = AppSettings();
|
||||||
|
|
||||||
|
AppSettings get settings => _settings;
|
||||||
|
|
||||||
|
String batteryChemistryForDevice(String deviceId) {
|
||||||
|
final stored = _settings.batteryChemistryByDeviceId[deviceId];
|
||||||
|
if (stored == 'liion') return 'nmc';
|
||||||
|
return stored ?? 'nmc';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadSettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonStr = prefs.getString(_settingsKey);
|
||||||
|
|
||||||
|
if (jsonStr != null) {
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||||
|
_settings = AppSettings.fromJson(json);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, use defaults
|
||||||
|
_settings = AppSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateSettings(AppSettings newSettings) async {
|
||||||
|
_settings = newSettings;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonStr = jsonEncode(_settings.toJson());
|
||||||
|
await prefs.setString(_settingsKey, jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setClearPathOnMaxRetry(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(clearPathOnMaxRetry: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMapShowRepeaters(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(mapShowRepeaters: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMapShowChatNodes(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(mapShowChatNodes: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMapShowOtherNodes(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(mapShowOtherNodes: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMapTimeFilterHours(double value) async {
|
||||||
|
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMapKeyPrefixEnabled(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(mapKeyPrefixEnabled: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMapKeyPrefix(String value) async {
|
||||||
|
await updateSettings(_settings.copyWith(mapKeyPrefix: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMapShowMarkers(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(mapShowMarkers: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNotificationsEnabled(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(notificationsEnabled: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNotifyOnNewMessage(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(notifyOnNewMessage: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNotifyOnNewAdvert(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(notifyOnNewAdvert: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAutoRouteRotationEnabled(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setThemeMode(String value) async {
|
||||||
|
await updateSettings(_settings.copyWith(themeMode: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
|
||||||
|
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
|
||||||
|
updated[deviceId] = chemistry;
|
||||||
|
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
|
class BleDebugLogEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final bool outgoing;
|
||||||
|
final String description;
|
||||||
|
final Uint8List payload;
|
||||||
|
|
||||||
|
BleDebugLogEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.outgoing,
|
||||||
|
required this.description,
|
||||||
|
required this.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get hexPreview {
|
||||||
|
const maxBytes = 64;
|
||||||
|
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
|
||||||
|
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||||
|
return payload.length > maxBytes ? '$hex …' : hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BleRawLogRxEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final Uint8List payload;
|
||||||
|
|
||||||
|
BleRawLogRxEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get hexPreview {
|
||||||
|
const maxBytes = 64;
|
||||||
|
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
|
||||||
|
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||||
|
return payload.length > maxBytes ? '$hex …' : hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BleDebugLogService extends ChangeNotifier {
|
||||||
|
static const int maxEntries = 500;
|
||||||
|
final List<BleDebugLogEntry> _entries = [];
|
||||||
|
final List<BleRawLogRxEntry> _rawLogRxEntries = [];
|
||||||
|
|
||||||
|
List<BleDebugLogEntry> get entries => List.unmodifiable(_entries);
|
||||||
|
List<BleRawLogRxEntry> get rawLogRxEntries => List.unmodifiable(_rawLogRxEntries);
|
||||||
|
|
||||||
|
void logFrame(Uint8List frame, {required bool outgoing, String? note}) {
|
||||||
|
if (frame.isEmpty) return;
|
||||||
|
final code = frame[0];
|
||||||
|
final description = _describeFrame(code, frame, outgoing, note);
|
||||||
|
_entries.add(
|
||||||
|
BleDebugLogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
outgoing: outgoing,
|
||||||
|
description: description,
|
||||||
|
payload: Uint8List.fromList(frame),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_entries.length > maxEntries) {
|
||||||
|
_entries.removeRange(0, _entries.length - maxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outgoing && code == pushCodeLogRxData && frame.length > 3) {
|
||||||
|
_rawLogRxEntries.add(
|
||||||
|
BleRawLogRxEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
payload: Uint8List.fromList(frame.sublist(3)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (_rawLogRxEntries.length > maxEntries) {
|
||||||
|
_rawLogRxEntries.removeRange(0, _rawLogRxEntries.length - maxEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_entries.clear();
|
||||||
|
_rawLogRxEntries.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _describeFrame(int code, Uint8List frame, bool outgoing, String? note) {
|
||||||
|
final label = _codeLabel(code);
|
||||||
|
final prefix = outgoing ? 'TX' : 'RX';
|
||||||
|
final extra = _frameDetail(code, frame);
|
||||||
|
final noteText = note != null ? ' • $note' : '';
|
||||||
|
return '$prefix $label$extra$noteText';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _codeLabel(int code) {
|
||||||
|
switch (code) {
|
||||||
|
case cmdAppStart:
|
||||||
|
return 'CMD_APP_START';
|
||||||
|
case cmdSendTxtMsg:
|
||||||
|
return 'CMD_SEND_TXT_MSG';
|
||||||
|
case cmdSendChannelTxtMsg:
|
||||||
|
return 'CMD_SEND_CHANNEL_TXT_MSG';
|
||||||
|
case cmdGetContacts:
|
||||||
|
return 'CMD_GET_CONTACTS';
|
||||||
|
case cmdGetDeviceTime:
|
||||||
|
return 'CMD_GET_DEVICE_TIME';
|
||||||
|
case cmdSetDeviceTime:
|
||||||
|
return 'CMD_SET_DEVICE_TIME';
|
||||||
|
case cmdSendSelfAdvert:
|
||||||
|
return 'CMD_SEND_SELF_ADVERT';
|
||||||
|
case cmdSetAdvertName:
|
||||||
|
return 'CMD_SET_ADVERT_NAME';
|
||||||
|
case cmdAddUpdateContact:
|
||||||
|
return 'CMD_ADD_UPDATE_CONTACT';
|
||||||
|
case cmdSyncNextMessage:
|
||||||
|
return 'CMD_SYNC_NEXT_MESSAGE';
|
||||||
|
case cmdSetRadioParams:
|
||||||
|
return 'CMD_SET_RADIO_PARAMS';
|
||||||
|
case cmdSetRadioTxPower:
|
||||||
|
return 'CMD_SET_RADIO_TX_POWER';
|
||||||
|
case cmdResetPath:
|
||||||
|
return 'CMD_RESET_PATH';
|
||||||
|
case cmdRemoveContact:
|
||||||
|
return 'CMD_REMOVE_CONTACT';
|
||||||
|
case cmdReboot:
|
||||||
|
return 'CMD_REBOOT';
|
||||||
|
case cmdGetBattAndStorage:
|
||||||
|
return 'CMD_GET_BATT_AND_STORAGE';
|
||||||
|
case cmdSendLogin:
|
||||||
|
return 'CMD_SEND_LOGIN';
|
||||||
|
case cmdGetChannel:
|
||||||
|
return 'CMD_GET_CHANNEL';
|
||||||
|
case cmdSetChannel:
|
||||||
|
return 'CMD_SET_CHANNEL';
|
||||||
|
case cmdGetRadioSettings:
|
||||||
|
return 'CMD_GET_RADIO_SETTINGS';
|
||||||
|
case respCodeOk:
|
||||||
|
return 'RESP_CODE_OK';
|
||||||
|
case respCodeErr:
|
||||||
|
return 'RESP_CODE_ERR';
|
||||||
|
case respCodeContactsStart:
|
||||||
|
return 'RESP_CODE_CONTACTS_START';
|
||||||
|
case respCodeContact:
|
||||||
|
return 'RESP_CODE_CONTACT';
|
||||||
|
case respCodeEndOfContacts:
|
||||||
|
return 'RESP_CODE_END_OF_CONTACTS';
|
||||||
|
case respCodeSelfInfo:
|
||||||
|
return 'RESP_CODE_SELF_INFO';
|
||||||
|
case respCodeSent:
|
||||||
|
return 'RESP_CODE_SENT';
|
||||||
|
case respCodeContactMsgRecv:
|
||||||
|
return 'RESP_CODE_CONTACT_MSG_RECV';
|
||||||
|
case respCodeChannelMsgRecv:
|
||||||
|
return 'RESP_CODE_CHANNEL_MSG_RECV';
|
||||||
|
case respCodeCurrTime:
|
||||||
|
return 'RESP_CODE_CURR_TIME';
|
||||||
|
case respCodeNoMoreMessages:
|
||||||
|
return 'RESP_CODE_NO_MORE_MESSAGES';
|
||||||
|
case respCodeBattAndStorage:
|
||||||
|
return 'RESP_CODE_BATT_AND_STORAGE';
|
||||||
|
case respCodeContactMsgRecvV3:
|
||||||
|
return 'RESP_CODE_CONTACT_MSG_RECV_V3';
|
||||||
|
case respCodeChannelMsgRecvV3:
|
||||||
|
return 'RESP_CODE_CHANNEL_MSG_RECV_V3';
|
||||||
|
case respCodeChannelInfo:
|
||||||
|
return 'RESP_CODE_CHANNEL_INFO';
|
||||||
|
case respCodeRadioSettings:
|
||||||
|
return 'RESP_CODE_RADIO_SETTINGS';
|
||||||
|
case pushCodeAdvert:
|
||||||
|
return 'PUSH_CODE_ADVERT';
|
||||||
|
case pushCodePathUpdated:
|
||||||
|
return 'PUSH_CODE_PATH_UPDATED';
|
||||||
|
case pushCodeSendConfirmed:
|
||||||
|
return 'PUSH_CODE_SEND_CONFIRMED';
|
||||||
|
case pushCodeMsgWaiting:
|
||||||
|
return 'PUSH_CODE_MSG_WAITING';
|
||||||
|
case pushCodeLoginSuccess:
|
||||||
|
return 'PUSH_CODE_LOGIN_SUCCESS';
|
||||||
|
case pushCodeLoginFail:
|
||||||
|
return 'PUSH_CODE_LOGIN_FAIL';
|
||||||
|
case pushCodeLogRxData:
|
||||||
|
return 'PUSH_CODE_LOG_RX_DATA';
|
||||||
|
case pushCodeNewAdvert:
|
||||||
|
return 'PUSH_CODE_NEW_ADVERT';
|
||||||
|
default:
|
||||||
|
return 'CODE_$code';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _frameDetail(int code, Uint8List frame) {
|
||||||
|
switch (code) {
|
||||||
|
case respCodeSent:
|
||||||
|
if (frame.length >= 10) {
|
||||||
|
final timeoutMs = readUint32LE(frame, 6);
|
||||||
|
return ' • timeout=${timeoutMs}ms';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
case pushCodeSendConfirmed:
|
||||||
|
if (frame.length >= 9) {
|
||||||
|
final tripMs = readUint32LE(frame, 5);
|
||||||
|
return ' • trip=${tripMs}ms';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
case pushCodeLoginSuccess:
|
||||||
|
return ' • login ok';
|
||||||
|
case pushCodeLoginFail:
|
||||||
|
return ' • login fail';
|
||||||
|
case respCodeBattAndStorage:
|
||||||
|
if (frame.length >= 3) {
|
||||||
|
final mv = readUint16LE(frame, 1);
|
||||||
|
return ' • ${mv}mV';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class MapMarkerService {
|
||||||
|
static const String _removedKey = 'map_removed_marker_ids';
|
||||||
|
|
||||||
|
Future<Set<String>> loadRemovedIds() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final items = prefs.getStringList(_removedKey) ?? const [];
|
||||||
|
return items.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveRemovedIds(Set<String> ids) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_removedKey, ids.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,348 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../models/message.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
|
import 'storage_service.dart';
|
||||||
|
import 'app_settings_service.dart';
|
||||||
|
|
||||||
|
class MessageRetryService extends ChangeNotifier {
|
||||||
|
static const int maxRetries = 5;
|
||||||
|
|
||||||
|
final StorageService _storage;
|
||||||
|
final Map<String, Timer> _timeoutTimers = {};
|
||||||
|
final Map<String, Message> _pendingMessages = {};
|
||||||
|
final Map<String, Contact> _pendingContacts = {};
|
||||||
|
final Map<String, PathSelection> _pendingPathSelections = {};
|
||||||
|
|
||||||
|
Function(Contact, String, bool, int, int)? _sendMessageCallback;
|
||||||
|
Function(String, Message)? _addMessageCallback;
|
||||||
|
Function(Message)? _updateMessageCallback;
|
||||||
|
Function(Contact)? _clearContactPathCallback;
|
||||||
|
Function(int, int)? _calculateTimeoutCallback;
|
||||||
|
AppSettingsService? _appSettingsService;
|
||||||
|
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
||||||
|
|
||||||
|
MessageRetryService(this._storage);
|
||||||
|
|
||||||
|
void initialize({
|
||||||
|
required Function(Contact, String, bool, int, int) sendMessageCallback,
|
||||||
|
required Function(String, Message) addMessageCallback,
|
||||||
|
required Function(Message) updateMessageCallback,
|
||||||
|
Function(Contact)? clearContactPathCallback,
|
||||||
|
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
|
||||||
|
AppSettingsService? appSettingsService,
|
||||||
|
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
||||||
|
}) {
|
||||||
|
_sendMessageCallback = sendMessageCallback;
|
||||||
|
_addMessageCallback = addMessageCallback;
|
||||||
|
_updateMessageCallback = updateMessageCallback;
|
||||||
|
_clearContactPathCallback = clearContactPathCallback;
|
||||||
|
_calculateTimeoutCallback = calculateTimeoutCallback;
|
||||||
|
_appSettingsService = appSettingsService;
|
||||||
|
_recordPathResultCallback = recordPathResultCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sendMessageWithRetry({
|
||||||
|
required Contact contact,
|
||||||
|
required String text,
|
||||||
|
bool forceFlood = false,
|
||||||
|
PathSelection? pathSelection,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
int? pathLength,
|
||||||
|
}) async {
|
||||||
|
final messageId = const Uuid().v4();
|
||||||
|
final effectiveForceFlood = forceFlood || (pathSelection?.useFlood ?? false);
|
||||||
|
final messagePathBytes =
|
||||||
|
pathBytes ?? _resolveMessagePathBytes(contact, effectiveForceFlood, pathSelection);
|
||||||
|
final messagePathLength =
|
||||||
|
pathLength ?? _resolveMessagePathLength(contact, effectiveForceFlood, pathSelection);
|
||||||
|
final message = Message(
|
||||||
|
senderKey: contact.publicKey,
|
||||||
|
text: text,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
isOutgoing: true,
|
||||||
|
status: MessageStatus.pending,
|
||||||
|
messageId: messageId,
|
||||||
|
retryCount: 0,
|
||||||
|
forceFlood: effectiveForceFlood,
|
||||||
|
pathLength: messagePathLength,
|
||||||
|
pathBytes: messagePathBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingMessages[messageId] = message;
|
||||||
|
_pendingContacts[messageId] = contact;
|
||||||
|
if (pathSelection != null) {
|
||||||
|
_pendingPathSelections[messageId] = pathSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_addMessageCallback != null) {
|
||||||
|
_addMessageCallback!(contact.publicKeyHex, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _attemptSend(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _attemptSend(String messageId) async {
|
||||||
|
final message = _pendingMessages[messageId];
|
||||||
|
final contact = _pendingContacts[messageId];
|
||||||
|
|
||||||
|
if (message == null || contact == null) return;
|
||||||
|
|
||||||
|
Contact sendContact = contact;
|
||||||
|
final attempt = message.retryCount.clamp(0, 3);
|
||||||
|
|
||||||
|
if (message.forceFlood && contact.pathLength >= 0) {
|
||||||
|
sendContact = Contact(
|
||||||
|
publicKey: contact.publicKey,
|
||||||
|
name: contact.name,
|
||||||
|
type: contact.type,
|
||||||
|
pathLength: -1,
|
||||||
|
path: contact.path,
|
||||||
|
latitude: contact.latitude,
|
||||||
|
longitude: contact.longitude,
|
||||||
|
lastSeen: contact.lastSeen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sendMessageCallback != null) {
|
||||||
|
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
_sendMessageCallback!(
|
||||||
|
sendContact,
|
||||||
|
message.text,
|
||||||
|
message.forceFlood,
|
||||||
|
attempt,
|
||||||
|
timestampSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
|
||||||
|
for (var entry in _pendingMessages.entries) {
|
||||||
|
final message = entry.value;
|
||||||
|
if (message.status == MessageStatus.pending) {
|
||||||
|
final contact = _pendingContacts[entry.key];
|
||||||
|
final selection = _pendingPathSelections[entry.key];
|
||||||
|
|
||||||
|
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
|
||||||
|
int actualTimeout = timeoutMs;
|
||||||
|
if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) {
|
||||||
|
int pathLengthValue;
|
||||||
|
if (selection != null) {
|
||||||
|
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||||
|
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
|
||||||
|
} else if (message.pathLength != null) {
|
||||||
|
pathLengthValue = message.pathLength!;
|
||||||
|
} else {
|
||||||
|
pathLengthValue = message.forceFlood ? -1 : contact.pathLength;
|
||||||
|
}
|
||||||
|
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
|
||||||
|
debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops');
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedMessage = message.copyWith(
|
||||||
|
status: MessageStatus.sent,
|
||||||
|
expectedAckHash: ackHash,
|
||||||
|
estimatedTimeoutMs: actualTimeout,
|
||||||
|
sentAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingMessages[entry.key] = updatedMessage;
|
||||||
|
|
||||||
|
if (_updateMessageCallback != null) {
|
||||||
|
_updateMessageCallback!(updatedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
_startTimeoutTimer(entry.key, actualTimeout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startTimeoutTimer(String messageId, int timeoutMs) {
|
||||||
|
_timeoutTimers[messageId]?.cancel();
|
||||||
|
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
|
||||||
|
_handleTimeout(messageId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTimeout(String messageId) {
|
||||||
|
final message = _pendingMessages[messageId];
|
||||||
|
final contact = _pendingContacts[messageId];
|
||||||
|
final selection = _pendingPathSelections[messageId];
|
||||||
|
|
||||||
|
if (message == null || contact == null) return;
|
||||||
|
|
||||||
|
if (message.retryCount < maxRetries - 1) {
|
||||||
|
final backoffMs = 1000 * (1 << message.retryCount);
|
||||||
|
|
||||||
|
final updatedMessage = message.copyWith(
|
||||||
|
retryCount: message.retryCount + 1,
|
||||||
|
status: MessageStatus.pending,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingMessages[messageId] = updatedMessage;
|
||||||
|
|
||||||
|
if (_updateMessageCallback != null) {
|
||||||
|
_updateMessageCallback!(updatedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer(Duration(milliseconds: backoffMs), () {
|
||||||
|
_attemptSend(messageId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Max retries reached - mark as failed
|
||||||
|
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||||
|
|
||||||
|
_pendingMessages.remove(messageId);
|
||||||
|
_pendingContacts.remove(messageId);
|
||||||
|
_pendingPathSelections.remove(messageId);
|
||||||
|
_timeoutTimers[messageId]?.cancel();
|
||||||
|
_timeoutTimers.remove(messageId);
|
||||||
|
|
||||||
|
// Check if we should clear the path on max retry
|
||||||
|
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
||||||
|
_clearContactPathCallback != null) {
|
||||||
|
_clearContactPathCallback!(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null);
|
||||||
|
|
||||||
|
if (_updateMessageCallback != null) {
|
||||||
|
_updateMessageCallback!(failedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
|
||||||
|
String? matchedMessageId;
|
||||||
|
|
||||||
|
for (var entry in _pendingMessages.entries) {
|
||||||
|
final message = entry.value;
|
||||||
|
if (message.expectedAckHash != null &&
|
||||||
|
listEquals(message.expectedAckHash, ackHash)) {
|
||||||
|
matchedMessageId = entry.key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedMessageId != null) {
|
||||||
|
final message = _pendingMessages[matchedMessageId]!;
|
||||||
|
final contact = _pendingContacts[matchedMessageId];
|
||||||
|
final selection = _pendingPathSelections[matchedMessageId];
|
||||||
|
_timeoutTimers[matchedMessageId]?.cancel();
|
||||||
|
_timeoutTimers.remove(matchedMessageId);
|
||||||
|
|
||||||
|
final deliveredMessage = message.copyWith(
|
||||||
|
status: MessageStatus.delivered,
|
||||||
|
deliveredAt: DateTime.now(),
|
||||||
|
tripTimeMs: tripTimeMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingMessages.remove(matchedMessageId);
|
||||||
|
_pendingContacts.remove(matchedMessageId);
|
||||||
|
_pendingPathSelections.remove(matchedMessageId);
|
||||||
|
|
||||||
|
if (_updateMessageCallback != null) {
|
||||||
|
_updateMessageCallback!(deliveredMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact != null) {
|
||||||
|
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _resolveMessagePathBytes(
|
||||||
|
Contact contact,
|
||||||
|
bool forceFlood,
|
||||||
|
PathSelection? selection,
|
||||||
|
) {
|
||||||
|
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
||||||
|
return Uint8List(0);
|
||||||
|
}
|
||||||
|
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||||
|
return Uint8List.fromList(selection.pathBytes);
|
||||||
|
}
|
||||||
|
return contact.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _resolveMessagePathLength(
|
||||||
|
Contact contact,
|
||||||
|
bool forceFlood,
|
||||||
|
PathSelection? selection,
|
||||||
|
) {
|
||||||
|
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||||
|
return selection.hopCount;
|
||||||
|
}
|
||||||
|
return contact.pathLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getContactKeyForAckHash(Uint8List ackHash) {
|
||||||
|
for (var entry in _pendingMessages.entries) {
|
||||||
|
final message = entry.value;
|
||||||
|
if (message.expectedAckHash != null &&
|
||||||
|
listEquals(message.expectedAckHash, ackHash)) {
|
||||||
|
final contact = _pendingContacts[entry.key];
|
||||||
|
return contact?.publicKeyHex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int calculateDefaultTimeout(Contact contact) {
|
||||||
|
if (contact.pathLength < 0) {
|
||||||
|
return 15000;
|
||||||
|
} else {
|
||||||
|
return 3000 + (3000 * contact.pathLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recordPathResultFromMessage(
|
||||||
|
String contactKey,
|
||||||
|
Message message,
|
||||||
|
PathSelection? selection,
|
||||||
|
bool success,
|
||||||
|
int? tripTimeMs,
|
||||||
|
) {
|
||||||
|
if (_recordPathResultCallback == null) return;
|
||||||
|
final recordSelection = selection ?? _selectionFromMessage(message);
|
||||||
|
if (recordSelection == null) return;
|
||||||
|
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathSelection? _selectionFromMessage(Message message) {
|
||||||
|
if (message.forceFlood || (message.pathLength != null && message.pathLength! < 0)) {
|
||||||
|
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||||
|
}
|
||||||
|
if (message.pathBytes.isEmpty && message.pathLength == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PathSelection(
|
||||||
|
pathBytes: message.pathBytes,
|
||||||
|
hopCount: message.pathLength ?? message.pathBytes.length,
|
||||||
|
useFlood: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (var timer in _timeoutTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_timeoutTimers.clear();
|
||||||
|
_pendingMessages.clear();
|
||||||
|
_pendingContacts.clear();
|
||||||
|
_pendingPathSelections.clear();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
static final NotificationService _instance = NotificationService._internal();
|
||||||
|
factory NotificationService() => _instance;
|
||||||
|
NotificationService._internal();
|
||||||
|
|
||||||
|
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
|
const iosSettings = DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: true,
|
||||||
|
requestBadgePermission: true,
|
||||||
|
requestSoundPermission: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initSettings = InitializationSettings(
|
||||||
|
android: androidSettings,
|
||||||
|
iOS: iosSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _notifications.initialize(
|
||||||
|
initSettings,
|
||||||
|
onDidReceiveNotificationResponse: _onNotificationTapped,
|
||||||
|
);
|
||||||
|
_isInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error initializing notifications: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> requestPermissions() async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request Android 13+ notification permission
|
||||||
|
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
if (androidPlugin != null) {
|
||||||
|
final granted = await androidPlugin.requestNotificationsPermission();
|
||||||
|
return granted ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS permissions are requested during initialization
|
||||||
|
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin>();
|
||||||
|
if (iosPlugin != null) {
|
||||||
|
final granted = await iosPlugin.requestPermissions(
|
||||||
|
alert: true,
|
||||||
|
badge: true,
|
||||||
|
sound: true,
|
||||||
|
);
|
||||||
|
return granted ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showMessageNotification({
|
||||||
|
required String contactName,
|
||||||
|
required String message,
|
||||||
|
String? contactId,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
'messages',
|
||||||
|
'Messages',
|
||||||
|
channelDescription: 'New message notifications',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const notificationDetails = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
contactId?.hashCode ?? 0,
|
||||||
|
'New message from $contactName',
|
||||||
|
message.length > 100 ? '${message.substring(0, 100)}...' : message,
|
||||||
|
notificationDetails,
|
||||||
|
payload: 'message:$contactId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showAdvertNotification({
|
||||||
|
required String contactName,
|
||||||
|
required String contactType,
|
||||||
|
String? contactId,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
'adverts',
|
||||||
|
'Advertisements',
|
||||||
|
channelDescription: 'New node advertisement notifications',
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
priority: Priority.defaultPriority,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const notificationDetails = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'New $contactType discovered',
|
||||||
|
contactName,
|
||||||
|
notificationDetails,
|
||||||
|
payload: 'advert:$contactId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onNotificationTapped(NotificationResponse response) {
|
||||||
|
final payload = response.payload;
|
||||||
|
if (payload != null) {
|
||||||
|
debugPrint('Notification tapped: $payload');
|
||||||
|
// Handle navigation based on payload
|
||||||
|
// This can be extended to navigate to specific screens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelAll() async {
|
||||||
|
await _notifications.cancelAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancel(int id) async {
|
||||||
|
await _notifications.cancel(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,324 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../models/path_history.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
|
import 'storage_service.dart';
|
||||||
|
|
||||||
|
class PathHistoryService extends ChangeNotifier {
|
||||||
|
final StorageService _storage;
|
||||||
|
final Map<String, ContactPathHistory> _cache = {};
|
||||||
|
final Map<String, int> _autoRotationIndex = {};
|
||||||
|
final Map<String, _FloodStats> _floodStats = {};
|
||||||
|
|
||||||
|
static const int _maxHistoryEntries = 100;
|
||||||
|
static const int _autoRotationTopCount = 3;
|
||||||
|
|
||||||
|
PathHistoryService(this._storage);
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
// Load cached path histories on startup if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
void handlePathUpdated(Contact contact) {
|
||||||
|
if (contact.pathLength < 0) return;
|
||||||
|
|
||||||
|
_addPathRecord(
|
||||||
|
contactPubKeyHex: contact.publicKeyHex,
|
||||||
|
hopCount: contact.pathLength,
|
||||||
|
tripTimeMs: 0,
|
||||||
|
wasFloodDiscovery: true,
|
||||||
|
pathBytes: contact.path,
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordPathAttempt(String contactPubKeyHex, PathSelection selection) {
|
||||||
|
if (selection.useFlood) {
|
||||||
|
_updateFloodStats(contactPubKeyHex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_addPathRecord(
|
||||||
|
contactPubKeyHex: contactPubKeyHex,
|
||||||
|
hopCount: selection.hopCount,
|
||||||
|
tripTimeMs: 0,
|
||||||
|
wasFloodDiscovery: false,
|
||||||
|
pathBytes: selection.pathBytes,
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordPathResult(
|
||||||
|
String contactPubKeyHex,
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool success,
|
||||||
|
int? tripTimeMs,
|
||||||
|
}) {
|
||||||
|
if (selection.useFlood) {
|
||||||
|
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
|
||||||
|
if (success) {
|
||||||
|
stats.successCount += 1;
|
||||||
|
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
|
||||||
|
} else {
|
||||||
|
stats.failureCount += 1;
|
||||||
|
}
|
||||||
|
stats.lastUsed = DateTime.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existing = _findPathRecord(contactPubKeyHex, selection.pathBytes);
|
||||||
|
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
|
||||||
|
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
|
||||||
|
|
||||||
|
_addPathRecord(
|
||||||
|
contactPubKeyHex: contactPubKeyHex,
|
||||||
|
hopCount: selection.hopCount,
|
||||||
|
tripTimeMs: success ? (tripTimeMs ?? 0) : (existing?.tripTimeMs ?? 0),
|
||||||
|
wasFloodDiscovery: existing?.wasFloodDiscovery ?? false,
|
||||||
|
pathBytes: selection.pathBytes,
|
||||||
|
successCount: successCount,
|
||||||
|
failureCount: failureCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
|
||||||
|
final ranked = _getRankedPaths(contactPubKeyHex)
|
||||||
|
.take(_autoRotationTopCount)
|
||||||
|
.toList();
|
||||||
|
if (ranked.isEmpty) {
|
||||||
|
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final selections = ranked
|
||||||
|
.map((path) => PathSelection(
|
||||||
|
pathBytes: path.pathBytes,
|
||||||
|
hopCount: path.hopCount,
|
||||||
|
useFlood: false,
|
||||||
|
))
|
||||||
|
.toList()
|
||||||
|
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true));
|
||||||
|
|
||||||
|
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
|
||||||
|
final selection = selections[currentIndex % selections.length];
|
||||||
|
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addPathRecord({
|
||||||
|
required String contactPubKeyHex,
|
||||||
|
required int hopCount,
|
||||||
|
required int tripTimeMs,
|
||||||
|
required bool wasFloodDiscovery,
|
||||||
|
required List<int> pathBytes,
|
||||||
|
required int successCount,
|
||||||
|
required int failureCount,
|
||||||
|
}) {
|
||||||
|
var history = _cache[contactPubKeyHex];
|
||||||
|
|
||||||
|
if (history == null) {
|
||||||
|
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
|
||||||
|
if (loaded != null) {
|
||||||
|
_cache[contactPubKeyHex] = loaded;
|
||||||
|
_addPathRecordInternal(
|
||||||
|
contactPubKeyHex,
|
||||||
|
hopCount,
|
||||||
|
tripTimeMs,
|
||||||
|
wasFloodDiscovery,
|
||||||
|
pathBytes,
|
||||||
|
successCount,
|
||||||
|
failureCount,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_cache[contactPubKeyHex] = ContactPathHistory(
|
||||||
|
contactPubKeyHex: contactPubKeyHex,
|
||||||
|
recentPaths: [],
|
||||||
|
);
|
||||||
|
_addPathRecordInternal(
|
||||||
|
contactPubKeyHex,
|
||||||
|
hopCount,
|
||||||
|
tripTimeMs,
|
||||||
|
wasFloodDiscovery,
|
||||||
|
pathBytes,
|
||||||
|
successCount,
|
||||||
|
failureCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_addPathRecordInternal(
|
||||||
|
contactPubKeyHex,
|
||||||
|
hopCount,
|
||||||
|
tripTimeMs,
|
||||||
|
wasFloodDiscovery,
|
||||||
|
pathBytes,
|
||||||
|
successCount,
|
||||||
|
failureCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addPathRecordInternal(
|
||||||
|
String contactPubKeyHex,
|
||||||
|
int hopCount,
|
||||||
|
int tripTimeMs,
|
||||||
|
bool wasFloodDiscovery,
|
||||||
|
List<int> pathBytes,
|
||||||
|
int successCount,
|
||||||
|
int failureCount,
|
||||||
|
) {
|
||||||
|
var history = _cache[contactPubKeyHex];
|
||||||
|
if (history == null) return;
|
||||||
|
|
||||||
|
final existing = _findPathRecord(contactPubKeyHex, pathBytes);
|
||||||
|
if (existing != null) {
|
||||||
|
successCount = successCount == 0 ? existing.successCount : successCount;
|
||||||
|
failureCount = failureCount == 0 ? existing.failureCount : failureCount;
|
||||||
|
if (tripTimeMs == 0) {
|
||||||
|
tripTimeMs = existing.tripTimeMs;
|
||||||
|
}
|
||||||
|
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newRecord = PathRecord(
|
||||||
|
hopCount: hopCount,
|
||||||
|
tripTimeMs: tripTimeMs,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
wasFloodDiscovery: wasFloodDiscovery,
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
successCount: successCount,
|
||||||
|
failureCount: failureCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedPaths = List<PathRecord>.from(history.recentPaths);
|
||||||
|
|
||||||
|
updatedPaths.removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes));
|
||||||
|
|
||||||
|
if (existing == null && updatedPaths.length >= _maxHistoryEntries) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedPaths.insert(0, newRecord);
|
||||||
|
|
||||||
|
final updatedHistory = ContactPathHistory(
|
||||||
|
contactPubKeyHex: contactPubKeyHex,
|
||||||
|
recentPaths: updatedPaths,
|
||||||
|
);
|
||||||
|
|
||||||
|
_cache[contactPubKeyHex] = updatedHistory;
|
||||||
|
_storage.savePathHistory(contactPubKeyHex, updatedHistory);
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PathRecord> getRecentPaths(String contactPubKeyHex) {
|
||||||
|
final history = _cache[contactPubKeyHex];
|
||||||
|
if (history != null) {
|
||||||
|
return history.recentPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
|
||||||
|
if (loaded != null) {
|
||||||
|
_cache[contactPubKeyHex] = loaded;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ContactPathHistory?> _loadHistoryFromStorage(
|
||||||
|
String contactPubKeyHex) async {
|
||||||
|
return await _storage.loadPathHistory(contactPubKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathRecord? getFastestPath(String contactPubKeyHex) {
|
||||||
|
final history = _cache[contactPubKeyHex];
|
||||||
|
return history?.fastest;
|
||||||
|
}
|
||||||
|
|
||||||
|
PathRecord? getMostRecentPath(String contactPubKeyHex) {
|
||||||
|
final history = _cache[contactPubKeyHex];
|
||||||
|
return history?.mostRecent;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPathHistory(String contactPubKeyHex) async {
|
||||||
|
_cache.remove(contactPubKeyHex);
|
||||||
|
_autoRotationIndex.remove(contactPubKeyHex);
|
||||||
|
_floodStats.remove(contactPubKeyHex);
|
||||||
|
await _storage.clearPathHistory(contactPubKeyHex);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removePathRecord(
|
||||||
|
String contactPubKeyHex,
|
||||||
|
List<int> pathBytes,
|
||||||
|
) async {
|
||||||
|
final history = _cache[contactPubKeyHex];
|
||||||
|
if (history == null) return;
|
||||||
|
|
||||||
|
final updatedPaths = List<PathRecord>.from(history.recentPaths)
|
||||||
|
..removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes));
|
||||||
|
|
||||||
|
_cache[contactPubKeyHex] = ContactPathHistory(
|
||||||
|
contactPubKeyHex: contactPubKeyHex,
|
||||||
|
recentPaths: updatedPaths,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _storage.savePathHistory(contactPubKeyHex, _cache[contactPubKeyHex]!);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
PathRecord? _findPathRecord(String contactPubKeyHex, List<int> pathBytes) {
|
||||||
|
final history = _cache[contactPubKeyHex];
|
||||||
|
if (history == null) return null;
|
||||||
|
for (final record in history.recentPaths) {
|
||||||
|
if (_pathsEqual(record.pathBytes, pathBytes)) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PathRecord> _getRankedPaths(String contactPubKeyHex) {
|
||||||
|
final history = _cache[contactPubKeyHex];
|
||||||
|
if (history == null) return [];
|
||||||
|
|
||||||
|
final ranked = List<PathRecord>.from(history.recentPaths)
|
||||||
|
..removeWhere((p) => p.pathBytes.isEmpty);
|
||||||
|
|
||||||
|
ranked.sort((a, b) {
|
||||||
|
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2);
|
||||||
|
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2);
|
||||||
|
if (aRate != bRate) return bRate.compareTo(aRate);
|
||||||
|
if (a.successCount != b.successCount) {
|
||||||
|
return b.successCount.compareTo(a.successCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
|
||||||
|
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
|
||||||
|
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
|
||||||
|
return b.timestamp.compareTo(a.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ranked;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _pathsEqual(List<int> a, List<int> b) {
|
||||||
|
return listEquals(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateFloodStats(String contactPubKeyHex) {
|
||||||
|
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
|
||||||
|
stats.lastUsed = DateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FloodStats {
|
||||||
|
int successCount = 0;
|
||||||
|
int failureCount = 0;
|
||||||
|
int lastTripTimeMs = 0;
|
||||||
|
DateTime? lastUsed;
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
|
class RepeaterCommandService {
|
||||||
|
final MeshCoreConnector _connector;
|
||||||
|
final Map<String, Completer<String>> _pendingCommands = {};
|
||||||
|
final Map<String, Timer> _commandTimeouts = {};
|
||||||
|
final Map<String, String> _commandPrefixes = {};
|
||||||
|
final Map<String, String> _pendingByPrefix = {};
|
||||||
|
int _prefixCounter = 0;
|
||||||
|
|
||||||
|
static const int timeoutSeconds = 10; // Flood mode timeout
|
||||||
|
static const int maxRetries = 5;
|
||||||
|
|
||||||
|
RepeaterCommandService(this._connector);
|
||||||
|
|
||||||
|
/// Send a CLI command to a repeater with automatic retries
|
||||||
|
/// Returns a future that completes when a response is received or after max retries
|
||||||
|
Future<String> sendCommand(
|
||||||
|
Contact repeater,
|
||||||
|
String command, {
|
||||||
|
Function(String)? onResponse,
|
||||||
|
Function(int)? onAttempt,
|
||||||
|
}) async {
|
||||||
|
final repeaterKey = repeater.publicKeyHex;
|
||||||
|
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
|
||||||
|
if (hasPending) {
|
||||||
|
throw Exception('Another command is still awaiting a response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create completer for this command
|
||||||
|
final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final completer = Completer<String>();
|
||||||
|
_pendingCommands[commandId] = completer;
|
||||||
|
|
||||||
|
onAttempt?.call(0);
|
||||||
|
|
||||||
|
// Send frame once (no retries)
|
||||||
|
try {
|
||||||
|
final prefix = _nextPrefixToken();
|
||||||
|
_commandPrefixes[commandId] = prefix;
|
||||||
|
_pendingByPrefix[prefix] = commandId;
|
||||||
|
final framedCommand = '$prefix$command';
|
||||||
|
final frame = buildSendCliCommandFrame(repeater.publicKey, framedCommand, attempt: 0);
|
||||||
|
await _connector.sendFrame(frame);
|
||||||
|
} catch (e) {
|
||||||
|
_cleanup(commandId);
|
||||||
|
throw Exception('Failed to send command: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timeout for this attempt
|
||||||
|
_commandTimeouts[commandId]?.cancel();
|
||||||
|
_commandTimeouts[commandId] = Timer(
|
||||||
|
Duration(seconds: timeoutSeconds),
|
||||||
|
() {
|
||||||
|
final completer = _pendingCommands[commandId];
|
||||||
|
if (completer != null && !completer.isCompleted) {
|
||||||
|
completer.completeError('Command timeout after $timeoutSeconds seconds');
|
||||||
|
_cleanup(commandId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for response or timeout
|
||||||
|
try {
|
||||||
|
final response = await completer.future;
|
||||||
|
return response;
|
||||||
|
} finally {
|
||||||
|
_cleanup(commandId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call this when a text message response is received from a repeater
|
||||||
|
void handleResponse(Contact repeater, String responseText) {
|
||||||
|
// Find pending command for this repeater and complete it
|
||||||
|
final repeaterKey = repeater.publicKeyHex;
|
||||||
|
|
||||||
|
String? commandId;
|
||||||
|
String responsePayload = responseText;
|
||||||
|
if (responseText.length >= 3 && responseText[2] == '|') {
|
||||||
|
final prefix = responseText.substring(0, 3);
|
||||||
|
commandId = _pendingByPrefix[prefix];
|
||||||
|
responsePayload = responseText.substring(3).trimLeft();
|
||||||
|
}
|
||||||
|
|
||||||
|
commandId ??= _pendingCommands.keys.firstWhere(
|
||||||
|
(id) => id.startsWith(repeaterKey),
|
||||||
|
orElse: () => '',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (commandId.isEmpty) return;
|
||||||
|
|
||||||
|
final completer = _pendingCommands[commandId];
|
||||||
|
if (completer != null && !completer.isCompleted) {
|
||||||
|
completer.complete(responsePayload);
|
||||||
|
_cleanup(commandId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cleanup(String commandId) {
|
||||||
|
_commandTimeouts[commandId]?.cancel();
|
||||||
|
_commandTimeouts.remove(commandId);
|
||||||
|
_pendingCommands.remove(commandId);
|
||||||
|
final prefix = _commandPrefixes.remove(commandId);
|
||||||
|
if (prefix != null) {
|
||||||
|
_pendingByPrefix.remove(prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
for (final timer in _commandTimeouts.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_commandTimeouts.clear();
|
||||||
|
_pendingCommands.clear();
|
||||||
|
_commandPrefixes.clear();
|
||||||
|
_pendingByPrefix.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _nextPrefixToken() {
|
||||||
|
for (var i = 0; i < 256; i++) {
|
||||||
|
final value = _prefixCounter++ & 0xFF;
|
||||||
|
final token = '${value.toRadixString(16).padLeft(2, '0').toUpperCase()}|';
|
||||||
|
if (!_pendingByPrefix.containsKey(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '00|';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../models/path_history.dart';
|
||||||
|
|
||||||
|
class StorageService {
|
||||||
|
static const String _pathHistoryPrefix = 'path_history_';
|
||||||
|
static const String _pendingMessagesKey = 'pending_messages';
|
||||||
|
static const String _repeaterPasswordsKey = 'repeater_passwords';
|
||||||
|
|
||||||
|
Future<void> savePathHistory(
|
||||||
|
String contactPubKeyHex, ContactPathHistory history) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = '$_pathHistoryPrefix$contactPubKeyHex';
|
||||||
|
final jsonStr = jsonEncode(history.toJson());
|
||||||
|
await prefs.setString(key, jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ContactPathHistory?> loadPathHistory(String contactPubKeyHex) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = '$_pathHistoryPrefix$contactPubKeyHex';
|
||||||
|
final jsonStr = prefs.getString(key);
|
||||||
|
|
||||||
|
if (jsonStr == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||||
|
return ContactPathHistory.fromJson(contactPubKeyHex, json);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPathHistory(String contactPubKeyHex) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = '$_pathHistoryPrefix$contactPubKeyHex';
|
||||||
|
await prefs.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearAllPathHistories() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final keys = prefs.getKeys();
|
||||||
|
final pathHistoryKeys =
|
||||||
|
keys.where((key) => key.startsWith(_pathHistoryPrefix));
|
||||||
|
|
||||||
|
for (final key in pathHistoryKeys) {
|
||||||
|
await prefs.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, String>> loadPendingMessages() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonStr = prefs.getString(_pendingMessagesKey);
|
||||||
|
|
||||||
|
if (jsonStr == null) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||||
|
return json.map((key, value) => MapEntry(key, value as String));
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> savePendingMessages(Map<String, String> pending) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonStr = jsonEncode(pending);
|
||||||
|
await prefs.setString(_pendingMessagesKey, jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPendingMessages() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_pendingMessagesKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a repeater password by public key hex
|
||||||
|
Future<void> saveRepeaterPassword(
|
||||||
|
String repeaterPubKeyHex, String password) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final passwords = await loadRepeaterPasswords();
|
||||||
|
passwords[repeaterPubKeyHex] = password;
|
||||||
|
final jsonStr = jsonEncode(passwords);
|
||||||
|
await prefs.setString(_repeaterPasswordsKey, jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all saved repeater passwords (map of pubKeyHex -> password)
|
||||||
|
Future<Map<String, String>> loadRepeaterPasswords() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonStr = prefs.getString(_repeaterPasswordsKey);
|
||||||
|
|
||||||
|
if (jsonStr == null) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||||
|
return json.map((key, value) => MapEntry(key, value as String));
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific repeater's saved password
|
||||||
|
Future<String?> getRepeaterPassword(String repeaterPubKeyHex) async {
|
||||||
|
final passwords = await loadRepeaterPasswords();
|
||||||
|
return passwords[repeaterPubKeyHex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a saved repeater password
|
||||||
|
Future<void> removeRepeaterPassword(String repeaterPubKeyHex) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final passwords = await loadRepeaterPasswords();
|
||||||
|
passwords.remove(repeaterPubKeyHex);
|
||||||
|
final jsonStr = jsonEncode(passwords);
|
||||||
|
await prefs.setString(_repeaterPasswordsKey, jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all saved repeater passwords
|
||||||
|
Future<void> clearAllRepeaterPasswords() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_repeaterPasswordsKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../models/channel_message.dart';
|
||||||
|
import '../helpers/smaz.dart';
|
||||||
|
|
||||||
|
class ChannelMessageStore {
|
||||||
|
static const String _keyPrefix = 'channel_messages_';
|
||||||
|
|
||||||
|
/// Save messages for a specific channel
|
||||||
|
Future<void> saveChannelMessages(int channelIndex, List<ChannelMessage> messages) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = '$_keyPrefix$channelIndex';
|
||||||
|
|
||||||
|
// Convert messages to JSON
|
||||||
|
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
||||||
|
final jsonString = jsonEncode(jsonList);
|
||||||
|
|
||||||
|
await prefs.setString(key, jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load messages for a specific channel
|
||||||
|
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = '$_keyPrefix$channelIndex';
|
||||||
|
|
||||||
|
final jsonString = prefs.getString(key);
|
||||||
|
if (jsonString == null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList.map((json) => _messageFromJson(json)).toList();
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, return empty list
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear messages for a specific channel
|
||||||
|
Future<void> clearChannelMessages(int channelIndex) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = '$_keyPrefix$channelIndex';
|
||||||
|
await prefs.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all channel messages
|
||||||
|
Future<void> clearAllChannelMessages() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix));
|
||||||
|
for (var key in keys) {
|
||||||
|
await prefs.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert ChannelMessage to JSON map
|
||||||
|
Map<String, dynamic> _messageToJson(ChannelMessage msg) {
|
||||||
|
return {
|
||||||
|
'senderKey': msg.senderKey != null ? base64Encode(msg.senderKey!) : null,
|
||||||
|
'senderName': msg.senderName,
|
||||||
|
'text': msg.text,
|
||||||
|
'timestamp': msg.timestamp.millisecondsSinceEpoch,
|
||||||
|
'isOutgoing': msg.isOutgoing,
|
||||||
|
'status': msg.status.index,
|
||||||
|
'channelIndex': msg.channelIndex,
|
||||||
|
'repeatCount': msg.repeatCount,
|
||||||
|
'pathLength': msg.pathLength,
|
||||||
|
'pathBytes': base64Encode(msg.pathBytes),
|
||||||
|
'repeats': msg.repeats.map(_repeatToJson).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert JSON map to ChannelMessage
|
||||||
|
ChannelMessage _messageFromJson(Map<String, dynamic> json) {
|
||||||
|
final rawText = json['text'] as String;
|
||||||
|
final decodedText = Smaz.tryDecodePrefixed(rawText) ?? rawText;
|
||||||
|
return ChannelMessage(
|
||||||
|
senderKey: json['senderKey'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['senderKey']))
|
||||||
|
: null,
|
||||||
|
senderName: json['senderName'] as String,
|
||||||
|
text: decodedText,
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int),
|
||||||
|
isOutgoing: json['isOutgoing'] as bool,
|
||||||
|
status: ChannelMessageStatus.values[json['status'] as int],
|
||||||
|
repeatCount: (json['repeatCount'] as int?) ?? 0,
|
||||||
|
pathLength: json['pathLength'] as int?,
|
||||||
|
pathBytes: json['pathBytes'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
|
||||||
|
: Uint8List(0),
|
||||||
|
repeats: (json['repeats'] as List<dynamic>?)
|
||||||
|
?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
channelIndex: json['channelIndex'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _repeatToJson(Repeat repeat) {
|
||||||
|
return {
|
||||||
|
'repeaterKey': repeat.repeaterKey != null ? base64Encode(repeat.repeaterKey!) : null,
|
||||||
|
'repeaterName': repeat.repeaterName,
|
||||||
|
'tripTimeMs': repeat.tripTimeMs,
|
||||||
|
'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeat _repeatFromJson(Map<String, dynamic> json) {
|
||||||
|
return Repeat(
|
||||||
|
repeaterKey: json['repeaterKey'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['repeaterKey']))
|
||||||
|
: null,
|
||||||
|
repeaterName: json['repeaterName'] as String? ?? 'Unknown',
|
||||||
|
tripTimeMs: json['tripTimeMs'] as int? ?? 0,
|
||||||
|
path: (json['path'] as List<dynamic>?)
|
||||||
|
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||