Compare commits
13 Commits
725d5bb44f
...
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 564f91a96e | |||
| 5a99fec3be | |||
| 17c5596710 | |||
| 87c9a74b97 | |||
| 1e9a82a714 | |||
| d87f00037a | |||
| 83a8a3aaef | |||
| 9fd0c210ae | |||
| 9dc86419e4 | |||
| 18b70de638 | |||
| ef59bf5011 | |||
| a510d1f299 | |||
| 7774dc8514 |
51
.meta/releases/v1.0.0.md
Normal file
51
.meta/releases/v1.0.0.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# v1.0.0 - Initial Release
|
||||||
|
|
||||||
|
**Release Date:** February 2026
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
BrandManager is here! The first public release of the BrandMeister network management app for mobile devices.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Device Management
|
||||||
|
- View all your registered BrandMeister devices
|
||||||
|
- See device details including ID, callsign, and connection status
|
||||||
|
- Device extension display for multi-device setups
|
||||||
|
|
||||||
|
### Last Activity
|
||||||
|
- Real-time activity feed showing recent network transmissions
|
||||||
|
- Talkgroup information with ID and name
|
||||||
|
- Call duration display
|
||||||
|
- Time-ago timestamps for easy reference
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Secure API token authentication
|
||||||
|
- Encrypted local storage for credentials
|
||||||
|
- Easy token retrieval link to BrandMeister profile
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- Sign out functionality
|
||||||
|
- Legal information (Impressum & Privacy Policy)
|
||||||
|
- Clean, intuitive interface
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
- iOS
|
||||||
|
- Android
|
||||||
|
- macOS
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
- Built with Flutter 3.10.7+
|
||||||
|
- Material 3 design with custom Brandmeister red theme (#a1181d)
|
||||||
|
- Secure token storage using flutter_secure_storage
|
||||||
|
- Real-time updates via WebSocket
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This is the initial release of BrandManager. We welcome feedback and bug reports at our repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Developed by Bearologics GmbH*
|
||||||
BIN
.meta/screenshots/1.png
Normal file
BIN
.meta/screenshots/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
BIN
.meta/screenshots/2.png
Normal file
BIN
.meta/screenshots/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
BIN
.meta/screenshots/3.png
Normal file
BIN
.meta/screenshots/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
BIN
.meta/screenshots/4.png
Normal file
BIN
.meta/screenshots/4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
23
LICENSE.md
Normal file
23
LICENSE.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# License
|
||||||
|
|
||||||
|
COPYRIGHT AND PERMISSION NOTICE
|
||||||
|
|
||||||
|
Copyright (c) 2026, Bearologics GmbH, and contributors.
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and distribute this software for any purpose
|
||||||
|
with or without fee is hereby granted, provided that the above copyright notice
|
||||||
|
and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||||
|
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||||
|
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
Except as contained in this notice, the name of a copyright holder shall not
|
||||||
|
be used in advertising or otherwise to promote the sale, use or other dealings
|
||||||
|
in this Software without prior written authorization of the copyright holder.
|
||||||
81
README.md
81
README.md
@@ -1,16 +1,79 @@
|
|||||||
# bmmanager
|
# BrandManager
|
||||||
|
|
||||||
A new Flutter project.
|
Your DMR network, simplified.
|
||||||
|
|
||||||
|
BrandManager is a mobile application for managing your [BrandMeister](https://brandmeister.network) DMR network devices. Built with Flutter, it provides a clean and intuitive interface to monitor and control your hotspots and repeaters.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Device Management** - View and manage all your registered BrandMeister devices
|
||||||
|
- **Last Activity** - Monitor recent network activity with real-time updates
|
||||||
|
- **Secure Authentication** - API tokens are stored securely in encrypted storage on your device
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| | | | |
|
||||||
|
|:---:|:---:|:---:|:---:|
|
||||||
|
|  |  |  |  |
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
This project is a starting point for a Flutter application.
|
### Prerequisites
|
||||||
|
|
||||||
A few resources to get you started if this is your first Flutter project:
|
- Flutter SDK 3.10.7 or later
|
||||||
|
- A BrandMeister account with an API token
|
||||||
|
|
||||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
### Installation
|
||||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
1. Clone the repository:
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
```bash
|
||||||
samples, guidance on mobile development, and a full API reference.
|
git clone https://git.bearologics.org/Bearologics/BrandManager.git
|
||||||
|
cd BrandManager
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the app:
|
||||||
|
```bash
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Your API Token
|
||||||
|
|
||||||
|
1. Log in to your [BrandMeister account](https://brandmeister.network)
|
||||||
|
2. Navigate to your profile settings
|
||||||
|
3. Generate an API token from the API section
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
```bash
|
||||||
|
flutter build ios
|
||||||
|
```
|
||||||
|
|
||||||
|
### Android
|
||||||
|
```bash
|
||||||
|
flutter build apk
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
```bash
|
||||||
|
flutter build macos
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under a curl-style license - see the [LICENSE.md](LICENSE.md) file for details.
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
Bearologics GmbH
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [BrandMeister Network](https://brandmeister.network)
|
||||||
|
- [Impressum](https://bearologics.com/impressum)
|
||||||
|
- [Privacy Policy](https://bearologics.com/privacy)
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,8 +8,14 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.bmmanager"
|
namespace = "gmbh.bearologics.brandmanager"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
@@ -19,9 +28,17 @@ android {
|
|||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||||
|
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
applicationId = "gmbh.bearologics.brandmanager"
|
||||||
applicationId = "com.example.bmmanager"
|
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -32,9 +49,7 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.example.bmmanager
|
package gmbh.bearologics.brandmanager
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager;
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -385,7 +385,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -402,7 +402,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -417,7 +417,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -549,7 +549,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager;
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -572,7 +572,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager;
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ class BrandManagerApp extends StatelessWidget {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: Colors.blue,
|
seedColor: const Color(0xFFa1181d),
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
darkTheme: ThemeData(
|
darkTheme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: Colors.blue,
|
seedColor: const Color(0xFFa1181d),
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
class HoseItem {
|
|
||||||
final int talkgroupId;
|
|
||||||
final String talkgroupName;
|
|
||||||
final String sourceCall;
|
|
||||||
final String? sourceName;
|
|
||||||
final int slot;
|
|
||||||
final DateTime lastActivity;
|
|
||||||
final bool isActive;
|
|
||||||
|
|
||||||
HoseItem({
|
|
||||||
required this.talkgroupId,
|
|
||||||
required this.talkgroupName,
|
|
||||||
required this.sourceCall,
|
|
||||||
this.sourceName,
|
|
||||||
required this.slot,
|
|
||||||
required this.lastActivity,
|
|
||||||
required this.isActive,
|
|
||||||
});
|
|
||||||
|
|
||||||
String get displayName {
|
|
||||||
if (sourceName != null && sourceName!.isNotEmpty) {
|
|
||||||
return '$sourceCall ($sourceName)';
|
|
||||||
}
|
|
||||||
return sourceCall;
|
|
||||||
}
|
|
||||||
|
|
||||||
HoseItem copyWith({
|
|
||||||
int? talkgroupId,
|
|
||||||
String? talkgroupName,
|
|
||||||
String? sourceCall,
|
|
||||||
String? sourceName,
|
|
||||||
int? slot,
|
|
||||||
DateTime? lastActivity,
|
|
||||||
bool? isActive,
|
|
||||||
}) {
|
|
||||||
return HoseItem(
|
|
||||||
talkgroupId: talkgroupId ?? this.talkgroupId,
|
|
||||||
talkgroupName: talkgroupName ?? this.talkgroupName,
|
|
||||||
sourceCall: sourceCall ?? this.sourceCall,
|
|
||||||
sourceName: sourceName ?? this.sourceName,
|
|
||||||
slot: slot ?? this.slot,
|
|
||||||
lastActivity: lastActivity ?? this.lastActivity,
|
|
||||||
isActive: isActive ?? this.isActive,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -119,9 +119,21 @@ class LastHeardItem {
|
|||||||
|
|
||||||
String get destinationDisplayName {
|
String get destinationDisplayName {
|
||||||
if (destinationName != null && destinationName!.isNotEmpty) {
|
if (destinationName != null && destinationName!.isNotEmpty) {
|
||||||
return '$destinationCall ($destinationName)';
|
return 'TG $destinationID $destinationName';
|
||||||
|
}
|
||||||
|
return 'TG $destinationID';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get durationDisplay {
|
||||||
|
final durationSeconds = stop - start;
|
||||||
|
if (durationSeconds < 0) return '';
|
||||||
|
if (durationSeconds < 60) {
|
||||||
|
return '${durationSeconds}s';
|
||||||
|
} else {
|
||||||
|
final minutes = durationSeconds ~/ 60;
|
||||||
|
final seconds = durationSeconds % 60;
|
||||||
|
return seconds > 0 ? '${minutes}m ${seconds}s' : '${minutes}m';
|
||||||
}
|
}
|
||||||
return destinationCall;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTime get timestamp {
|
DateTime get timestamp {
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
||||||
|
|
||||||
class MeActivityWebSocketClient {
|
|
||||||
WebSocketChannel? _channel;
|
|
||||||
final StreamController<Map<String, dynamic>> _messageController =
|
|
||||||
StreamController<Map<String, dynamic>>.broadcast();
|
|
||||||
Timer? _heartbeatTimer;
|
|
||||||
Timer? _reconnectTimer;
|
|
||||||
bool _isConnecting = false;
|
|
||||||
bool _shouldReconnect = true;
|
|
||||||
bool _isReady = false;
|
|
||||||
int _reconnectAttempts = 0;
|
|
||||||
static const int _maxReconnectAttempts = 5;
|
|
||||||
static const Duration _reconnectDelay = Duration(seconds: 5);
|
|
||||||
|
|
||||||
final List<int> _radioIds;
|
|
||||||
|
|
||||||
final Completer<void> _readyCompleter = Completer<void>();
|
|
||||||
|
|
||||||
MeActivityWebSocketClient({
|
|
||||||
required List<int> radioIds,
|
|
||||||
}) : _radioIds = radioIds;
|
|
||||||
|
|
||||||
static const String _wsUrl =
|
|
||||||
'wss://api.brandmeister.network/lh/?EIO=4&transport=websocket';
|
|
||||||
|
|
||||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
|
||||||
|
|
||||||
bool get isConnected => _channel != null;
|
|
||||||
|
|
||||||
bool get isReady => _isReady;
|
|
||||||
|
|
||||||
Future<void> get ready => _readyCompleter.future;
|
|
||||||
|
|
||||||
List<int> get radioIds => _radioIds;
|
|
||||||
|
|
||||||
Future<void> connect() async {
|
|
||||||
if (_isConnecting || isConnected) {
|
|
||||||
debugPrint('MeActivity WS: Already connected or connecting');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_radioIds.isEmpty) {
|
|
||||||
debugPrint('MeActivity WS: No radio IDs provided');
|
|
||||||
if (!_readyCompleter.isCompleted) {
|
|
||||||
_readyCompleter.complete();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isConnecting = true;
|
|
||||||
_shouldReconnect = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
debugPrint('MeActivity WS: Connecting to $_wsUrl');
|
|
||||||
|
|
||||||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
|
|
||||||
|
|
||||||
_channel!.stream.listen(
|
|
||||||
_handleMessage,
|
|
||||||
onError: _handleError,
|
|
||||||
onDone: _handleDisconnect,
|
|
||||||
cancelOnError: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
_reconnectAttempts = 0;
|
|
||||||
_isConnecting = false;
|
|
||||||
debugPrint('MeActivity WS: WebSocket connected');
|
|
||||||
} catch (e) {
|
|
||||||
_isConnecting = false;
|
|
||||||
debugPrint('MeActivity WS: Connection error: $e');
|
|
||||||
_scheduleReconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleMessage(dynamic message) {
|
|
||||||
try {
|
|
||||||
final messageStr = message.toString();
|
|
||||||
debugPrint('MeActivity WS: Received message: $messageStr');
|
|
||||||
|
|
||||||
if (messageStr.startsWith('0')) {
|
|
||||||
_handleOpenPacket(messageStr);
|
|
||||||
} else if (messageStr.startsWith('2')) {
|
|
||||||
_sendPong();
|
|
||||||
} else if (messageStr.startsWith('40')) {
|
|
||||||
_handleNamespaceConnect();
|
|
||||||
} else if (messageStr.startsWith('42')) {
|
|
||||||
_handleDataPacket(messageStr);
|
|
||||||
} else if (messageStr.startsWith('4')) {
|
|
||||||
debugPrint('MeActivity WS: Received other type 4 message: $messageStr');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('MeActivity WS: Error handling message: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleOpenPacket(String message) {
|
|
||||||
try {
|
|
||||||
final jsonStr = message.substring(1);
|
|
||||||
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
final pingInterval = data['pingInterval'] as int? ?? 25000;
|
|
||||||
|
|
||||||
_heartbeatTimer?.cancel();
|
|
||||||
_heartbeatTimer = Timer.periodic(
|
|
||||||
Duration(milliseconds: pingInterval),
|
|
||||||
(_) => _sendPing(),
|
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'MeActivity WS: Open packet received, ping interval: $pingInterval ms');
|
|
||||||
|
|
||||||
_sendNamespaceConnect();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('MeActivity WS: Error parsing open packet: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _sendNamespaceConnect() {
|
|
||||||
if (isConnected) {
|
|
||||||
try {
|
|
||||||
_channel!.sink.add('40');
|
|
||||||
debugPrint('MeActivity WS: Sent namespace connect (40)');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('MeActivity WS: Error sending namespace connect: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleNamespaceConnect() {
|
|
||||||
debugPrint('MeActivity WS: Namespace connected (received 40)');
|
|
||||||
|
|
||||||
_isReady = true;
|
|
||||||
if (!_readyCompleter.isCompleted) {
|
|
||||||
_readyCompleter.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send search query to fetch historical activity for user's devices
|
|
||||||
_sendSearchQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _sendSearchQuery() {
|
|
||||||
if (!isConnected || !isReady) {
|
|
||||||
debugPrint('MeActivity WS: Cannot send search query, not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build SQL query for multiple device IDs: ContextID = 123 OR ContextID = 456
|
|
||||||
final sqlParts = _radioIds.map((id) => 'ContextID = $id').toList();
|
|
||||||
final sql = sqlParts.join(' OR ');
|
|
||||||
|
|
||||||
final message = '42${jsonEncode([
|
|
||||||
'searchMongo',
|
|
||||||
{
|
|
||||||
'query': {'sql': sql},
|
|
||||||
'amount': 50 // Fetch up to 50 historical items
|
|
||||||
}
|
|
||||||
])}';
|
|
||||||
_channel!.sink.add(message);
|
|
||||||
debugPrint('MeActivity WS: Sent search query: $message');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('MeActivity WS: Error sending search query: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleDataPacket(String message) {
|
|
||||||
try {
|
|
||||||
String jsonStr = message.substring(1);
|
|
||||||
|
|
||||||
if (jsonStr.startsWith('2')) {
|
|
||||||
jsonStr = jsonStr.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = jsonDecode(jsonStr);
|
|
||||||
|
|
||||||
Map<String, dynamic>? eventData;
|
|
||||||
|
|
||||||
if (data is Map<String, dynamic>) {
|
|
||||||
eventData = data;
|
|
||||||
} else if (data is List && data.isNotEmpty) {
|
|
||||||
final eventName = data[0] as String;
|
|
||||||
if (data.length > 1) {
|
|
||||||
eventData = {
|
|
||||||
'event': eventName,
|
|
||||||
'data': data[1],
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
eventData = {
|
|
||||||
'event': eventName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventData != null && !_messageController.isClosed) {
|
|
||||||
_messageController.add(eventData);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('MeActivity WS: Error parsing data packet: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _sendPing() {
|
|
||||||
if (isConnected) {
|
|
||||||
try {
|
|
||||||
_channel!.sink.add('2');
|
|
||||||
debugPrint('MeActivity WS: Sent ping');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('MeActivity WS: Error sending ping: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _sendPong() {
|
|
||||||
if (isConnected) {
|
|
||||||
try {
|
|
||||||
_channel!.sink.add('3');
|
|
||||||
debugPrint('MeActivity WS: Sent pong');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('MeActivity WS: Error sending pong: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleError(Object error) {
|
|
||||||
debugPrint('MeActivity WS: Stream error: $error');
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleDisconnect() {
|
|
||||||
debugPrint('MeActivity WS: Disconnected');
|
|
||||||
_cleanup();
|
|
||||||
|
|
||||||
if (_shouldReconnect) {
|
|
||||||
_scheduleReconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleReconnect() {
|
|
||||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
|
||||||
debugPrint('MeActivity WS: Max reconnect attempts reached');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_reconnectAttempts++;
|
|
||||||
debugPrint('MeActivity WS: Scheduling reconnect attempt $_reconnectAttempts');
|
|
||||||
|
|
||||||
_reconnectTimer?.cancel();
|
|
||||||
_reconnectTimer = Timer(_reconnectDelay, () {
|
|
||||||
if (_shouldReconnect) {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _cleanup() {
|
|
||||||
_heartbeatTimer?.cancel();
|
|
||||||
_heartbeatTimer = null;
|
|
||||||
_channel = null;
|
|
||||||
_isConnecting = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void disconnect() {
|
|
||||||
debugPrint('MeActivity WS: Disconnecting');
|
|
||||||
_shouldReconnect = false;
|
|
||||||
_reconnectTimer?.cancel();
|
|
||||||
_heartbeatTimer?.cancel();
|
|
||||||
|
|
||||||
try {
|
|
||||||
_channel?.sink.close();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('MeActivity WS: Error closing channel: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
_cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
disconnect();
|
|
||||||
_messageController.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../services/authentication_manager.dart';
|
import '../services/authentication_manager.dart';
|
||||||
import '../services/brandmeister_client.dart';
|
import '../services/brandmeister_client.dart';
|
||||||
|
|
||||||
@@ -69,28 +70,72 @@ class _AuthViewState extends State<AuthView> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Center(
|
||||||
Icons.radio,
|
child: Container(
|
||||||
size: 80,
|
width: 100,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
image: const DecorationImage(
|
||||||
|
image: AssetImage('assets/icon/app_icon.png'),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Sign In',
|
'BrandManager',
|
||||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Enter your BrandMeister API token',
|
'Brandmeister Manager for Mobiles',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Please enter your BrandMeister API token',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
size: 20,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Your API token is stored securely in encrypted storage on your device. It is never shared with anyone and is only used to authenticate with the BrandMeister network.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _tokenController,
|
controller: _tokenController,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
@@ -133,7 +178,8 @@ class _AuthViewState extends State<AuthView> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Open BrandMeister website
|
final url = Uri.parse('https://brandmeister.network/?page=profile-api');
|
||||||
|
launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.open_in_new),
|
icon: const Icon(Icons.open_in_new),
|
||||||
label: const Text('Get API Token from BrandMeister'),
|
label: const Text('Get API Token from BrandMeister'),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/authentication_manager.dart';
|
import '../services/authentication_manager.dart';
|
||||||
import 'welcome_view.dart';
|
|
||||||
import 'auth_view.dart';
|
import 'auth_view.dart';
|
||||||
import 'main_view.dart';
|
import 'main_view.dart';
|
||||||
|
|
||||||
@@ -12,9 +11,13 @@ class ContentView extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authManager = context.watch<AuthenticationManager>();
|
final authManager = context.watch<AuthenticationManager>();
|
||||||
|
|
||||||
// Show splash screen while initializing
|
// Show loading indicator while initializing
|
||||||
if (authManager.isInitializing) {
|
if (authManager.isInitializing) {
|
||||||
return const WelcomeView();
|
return const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show main view if authenticated, otherwise show login
|
// Show main view if authenticated, otherwise show login
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
|
|||||||
_buildTalkgroupCategory(
|
_buildTalkgroupCategory(
|
||||||
'Static',
|
'Static',
|
||||||
_deviceProfile!.staticSubscriptions,
|
_deviceProfile!.staticSubscriptions,
|
||||||
Colors.blue,
|
Theme.of(context).colorScheme.primary,
|
||||||
Icons.link,
|
Icons.link,
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ class _DevicesViewState extends State<DevicesView> {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
..._devices.map((device) => _DeviceRow(
|
..._devices.map((device) => _DeviceRow(
|
||||||
device: device,
|
device: device,
|
||||||
|
baseRadioId: _devices.isNotEmpty ? _devices.first.id : null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -159,22 +160,46 @@ class _DevicesViewState extends State<DevicesView> {
|
|||||||
|
|
||||||
class _DeviceRow extends StatelessWidget {
|
class _DeviceRow extends StatelessWidget {
|
||||||
final Device device;
|
final Device device;
|
||||||
|
final int? baseRadioId;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _DeviceRow({
|
const _DeviceRow({
|
||||||
required this.device,
|
required this.device,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.baseRadioId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
String? get _deviceExtension {
|
||||||
|
if (baseRadioId == null) return null;
|
||||||
|
final baseStr = baseRadioId.toString();
|
||||||
|
final idStr = device.id.toString();
|
||||||
|
// Check if device ID starts with the base radio ID
|
||||||
|
if (idStr.startsWith(baseStr)) {
|
||||||
|
final extension = idStr.substring(baseStr.length);
|
||||||
|
return extension.isNotEmpty ? extension : '0';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final extension = _deviceExtension;
|
||||||
|
final showExtension = extension != null && extension != '0';
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||||
child: Icon(
|
child: showExtension
|
||||||
Icons.radio,
|
? Text(
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
extension,
|
||||||
),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.settings_input_antenna,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: Text(device.callsign ?? 'Unknown'),
|
title: Text(device.callsign ?? 'Unknown'),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
|
|||||||
@@ -1,326 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../models/hose_item.dart';
|
|
||||||
import '../services/lastheard_websocket_client.dart';
|
|
||||||
|
|
||||||
class HoseView extends StatefulWidget {
|
|
||||||
const HoseView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<HoseView> createState() => _HoseViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HoseViewState extends State<HoseView> {
|
|
||||||
final Map<int, HoseItem> _hoseItems = {};
|
|
||||||
LastHeardWebSocketClient? _wsClient;
|
|
||||||
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
|
|
||||||
Timer? _activityTimer;
|
|
||||||
bool _isConnecting = true;
|
|
||||||
|
|
||||||
// Time threshold to consider a talkgroup as "active" (5 seconds)
|
|
||||||
static const Duration _activityThreshold = Duration(seconds: 5);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_connectWebSocket();
|
|
||||||
_startActivityTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
_wsClient?.dispose();
|
|
||||||
_activityTimer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _connectWebSocket() async {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_wsClient = LastHeardWebSocketClient();
|
|
||||||
_wsClient!.connect();
|
|
||||||
|
|
||||||
_wsSubscription = _wsClient!.messageStream.listen((message) {
|
|
||||||
_handleMqttMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Give it a short moment to connect, then hide loading
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isConnecting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('HoseView: Error connecting to WebSocket: $e');
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isConnecting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleMqttMessage(Map<String, dynamic> message) {
|
|
||||||
try {
|
|
||||||
// Check if this is an MQTT event message
|
|
||||||
if (message['event'] == 'mqtt' && message['data'] is Map) {
|
|
||||||
final data = message['data'] as Map<String, dynamic>;
|
|
||||||
final topic = data['topic'] as String?;
|
|
||||||
|
|
||||||
if (topic == 'LH') {
|
|
||||||
final payloadStr = data['payload'] as String?;
|
|
||||||
if (payloadStr == null) return;
|
|
||||||
|
|
||||||
final payload = jsonDecode(payloadStr) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
final talkgroupId = payload['DestinationID'] as int?;
|
|
||||||
final talkgroupName = payload['DestinationName'] as String?;
|
|
||||||
final sourceCall = payload['SourceCall'] as String?;
|
|
||||||
final sourceName = payload['SourceName'] as String?;
|
|
||||||
final slot = payload['Slot'] as int?;
|
|
||||||
final event = payload['Event'] as String?;
|
|
||||||
final start = payload['Start'] as int?;
|
|
||||||
|
|
||||||
if (talkgroupId == null || sourceCall == null || slot == null || start == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final lastActivity = DateTime.fromMillisecondsSinceEpoch(start * 1000);
|
|
||||||
final isActive = event == 'Session-Start';
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_hoseItems[talkgroupId] = HoseItem(
|
|
||||||
talkgroupId: talkgroupId,
|
|
||||||
talkgroupName: talkgroupName ?? 'TG $talkgroupId',
|
|
||||||
sourceCall: sourceCall,
|
|
||||||
sourceName: sourceName,
|
|
||||||
slot: slot,
|
|
||||||
lastActivity: lastActivity,
|
|
||||||
isActive: isActive,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('HoseView: Error handling MQTT message: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startActivityTimer() {
|
|
||||||
// Update activity status every second
|
|
||||||
_activityTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
bool needsUpdate = false;
|
|
||||||
|
|
||||||
for (final item in _hoseItems.values) {
|
|
||||||
if (item.isActive && now.difference(item.lastActivity) > _activityThreshold) {
|
|
||||||
needsUpdate = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsUpdate) {
|
|
||||||
setState(() {
|
|
||||||
final now = DateTime.now();
|
|
||||||
_hoseItems.updateAll((key, item) {
|
|
||||||
if (item.isActive && now.difference(item.lastActivity) > _activityThreshold) {
|
|
||||||
return item.copyWith(isActive: false);
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Hose'),
|
|
||||||
),
|
|
||||||
body: _buildBody(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBody() {
|
|
||||||
if (_isConnecting) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_hoseItems.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.water_drop_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No active talkgroups',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Waiting for activity...',
|
|
||||||
style: TextStyle(color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final items = _hoseItems.values.toList()
|
|
||||||
..sort((a, b) => a.talkgroupId.compareTo(b.talkgroupId));
|
|
||||||
|
|
||||||
return GridView.builder(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 2,
|
|
||||||
childAspectRatio: 1.0,
|
|
||||||
crossAxisSpacing: 8,
|
|
||||||
mainAxisSpacing: 8,
|
|
||||||
),
|
|
||||||
itemCount: items.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = items[index];
|
|
||||||
return _buildHoseCard(item);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showNotImplementedSnackbar() {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Listening to live QSOs has not been implemented yet'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHoseCard(HoseItem item) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final timeSinceActivity = now.difference(item.lastActivity);
|
|
||||||
|
|
||||||
String timeAgo;
|
|
||||||
if (timeSinceActivity.inSeconds < 60) {
|
|
||||||
timeAgo = '${timeSinceActivity.inSeconds}s ago';
|
|
||||||
} else if (timeSinceActivity.inMinutes < 60) {
|
|
||||||
timeAgo = '${timeSinceActivity.inMinutes}m ago';
|
|
||||||
} else {
|
|
||||||
timeAgo = '${timeSinceActivity.inHours}h ago';
|
|
||||||
}
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: _showNotImplementedSnackbar,
|
|
||||||
child: Card(
|
|
||||||
elevation: item.isActive ? 4 : 1,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
side: item.isActive
|
|
||||||
? BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
width: 3,
|
|
||||||
)
|
|
||||||
: BorderSide.none,
|
|
||||||
),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Talkgroup header
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'TS${item.slot}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
if (item.isActive)
|
|
||||||
Icon(
|
|
||||||
Icons.mic,
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Talkgroup name
|
|
||||||
Expanded(
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
item.talkgroupName,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Last talker info
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.displayName,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'TG ${item.talkgroupId}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Colors.grey[600],
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
timeAgo,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Colors.grey[600],
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,10 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/last_heard_item.dart';
|
import '../models/last_heard_item.dart';
|
||||||
|
import '../models/user_info.dart';
|
||||||
import '../services/authentication_manager.dart';
|
import '../services/authentication_manager.dart';
|
||||||
import '../services/lastheard_websocket_client.dart';
|
import '../services/lastheard_websocket_client.dart';
|
||||||
|
import '../widgets/user_header.dart';
|
||||||
|
|
||||||
class LastHeardView extends StatefulWidget {
|
class LastHeardView extends StatefulWidget {
|
||||||
const LastHeardView({super.key});
|
const LastHeardView({super.key});
|
||||||
@@ -19,6 +21,8 @@ class _LastHeardViewState extends State<LastHeardView> {
|
|||||||
final List<LastHeardItem> _items = [];
|
final List<LastHeardItem> _items = [];
|
||||||
static const int _maxItems = 200;
|
static const int _maxItems = 200;
|
||||||
bool _isConnecting = true;
|
bool _isConnecting = true;
|
||||||
|
UserInfo? _userInfo;
|
||||||
|
String? _firstRadioId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -39,6 +43,7 @@ class _LastHeardViewState extends State<LastHeardView> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final authManager = context.read<AuthenticationManager>();
|
final authManager = context.read<AuthenticationManager>();
|
||||||
|
final userInfo = authManager.userInfo;
|
||||||
final devices = await authManager.getDevices();
|
final devices = await authManager.getDevices();
|
||||||
final radioIds = devices.map((d) => d.id).toList();
|
final radioIds = devices.map((d) => d.id).toList();
|
||||||
|
|
||||||
@@ -52,6 +57,8 @@ class _LastHeardViewState extends State<LastHeardView> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_userInfo = userInfo;
|
||||||
|
_firstRadioId = devices.isNotEmpty ? devices.first.id.toString() : null;
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,8 +78,10 @@ class _LastHeardViewState extends State<LastHeardView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
// Add to the beginning of the list
|
_items.add(item);
|
||||||
_items.insert(0, item);
|
|
||||||
|
// Sort by timestamp, most recent first
|
||||||
|
_items.sort((a, b) => b.start.compareTo(a.start));
|
||||||
|
|
||||||
// Keep only the latest 200 items
|
// Keep only the latest 200 items
|
||||||
if (_items.length > _maxItems) {
|
if (_items.length > _maxItems) {
|
||||||
@@ -136,9 +145,12 @@ class _LastHeardViewState extends State<LastHeardView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: _items.length,
|
itemCount: _items.length + 1,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = _items[index];
|
if (index == 0) {
|
||||||
|
return UserHeader(userInfo: _userInfo, radioId: _firstRadioId);
|
||||||
|
}
|
||||||
|
final item = _items[index - 1];
|
||||||
return _LastHeardItemTile(item: item);
|
return _LastHeardItemTile(item: item);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -150,123 +162,51 @@ class _LastHeardItemTile extends StatelessWidget {
|
|||||||
|
|
||||||
const _LastHeardItemTile({required this.item});
|
const _LastHeardItemTile({required this.item});
|
||||||
|
|
||||||
Color _getEventColor() {
|
|
||||||
switch (item.event) {
|
|
||||||
case 'Session-Start':
|
|
||||||
return Colors.green;
|
|
||||||
case 'Session-Stop':
|
|
||||||
return Colors.red;
|
|
||||||
default:
|
|
||||||
return Colors.blue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getEventIcon() {
|
|
||||||
switch (item.event) {
|
|
||||||
case 'Session-Start':
|
|
||||||
return Icons.phone_in_talk;
|
|
||||||
case 'Session-Stop':
|
|
||||||
return Icons.call_end;
|
|
||||||
default:
|
|
||||||
return Icons.radio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final eventColor = _getEventColor();
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: ListTile(
|
child: Padding(
|
||||||
leading: CircleAvatar(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
backgroundColor: eventColor.withValues(alpha: 0.2),
|
child: Row(
|
||||||
child: Icon(
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
_getEventIcon(),
|
|
||||||
color: eventColor,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_forward,
|
||||||
|
color: Colors.green,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
item.displayName,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.destinationDisplayName,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (item.durationDisplay.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
item.durationDisplay,
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
item.timeAgo,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.rssi != null)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[200],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'${item.rssi} dBm',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.arrow_forward, size: 12, color: Colors.grey[600]),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
item.destinationDisplayName,
|
|
||||||
style: TextStyle(color: Colors.grey[700]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: eventColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'TS${item.slot}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: eventColor,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
item.linkTypeName,
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'•',
|
|
||||||
style: TextStyle(color: Colors.grey[400]),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
item.timeAgo,
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
isThreeLine: true,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'me_view.dart';
|
|
||||||
import 'devices_view.dart';
|
import 'devices_view.dart';
|
||||||
import 'last_heard_view.dart';
|
import 'last_heard_view.dart';
|
||||||
import 'more_view.dart';
|
import 'more_view.dart';
|
||||||
@@ -15,7 +14,6 @@ class _MainViewState extends State<MainView> {
|
|||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
final List<Widget> _views = const [
|
final List<Widget> _views = const [
|
||||||
MeView(),
|
|
||||||
DevicesView(),
|
DevicesView(),
|
||||||
LastHeardView(),
|
LastHeardView(),
|
||||||
MoreView(),
|
MoreView(),
|
||||||
@@ -30,10 +28,6 @@ class _MainViewState extends State<MainView> {
|
|||||||
currentIndex: _selectedIndex,
|
currentIndex: _selectedIndex,
|
||||||
onTap: (index) => setState(() => _selectedIndex = index),
|
onTap: (index) => setState(() => _selectedIndex = index),
|
||||||
items: const [
|
items: const [
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.person),
|
|
||||||
label: 'Me',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.devices),
|
icon: Icon(Icons.devices),
|
||||||
label: 'Devices',
|
label: 'Devices',
|
||||||
@@ -43,8 +37,8 @@ class _MainViewState extends State<MainView> {
|
|||||||
label: 'Last Activity',
|
label: 'Last Activity',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.more_horiz),
|
icon: Icon(Icons.settings),
|
||||||
label: 'More',
|
label: 'Settings',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,366 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../models/device.dart';
|
|
||||||
import '../models/last_heard_item.dart';
|
|
||||||
import '../services/authentication_manager.dart';
|
|
||||||
import '../services/me_activity_websocket_client.dart';
|
|
||||||
import '../widgets/user_header.dart';
|
|
||||||
|
|
||||||
class MeView extends StatefulWidget {
|
|
||||||
const MeView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<MeView> createState() => _MeViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MeViewState extends State<MeView> {
|
|
||||||
MeActivityWebSocketClient? _wsClient;
|
|
||||||
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
|
|
||||||
List<Device> _devices = [];
|
|
||||||
final Map<int, List<LastHeardItem>> _itemsByDevice = {};
|
|
||||||
bool _isLoading = true;
|
|
||||||
static const int _maxItemsPerDevice = 5;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
_wsClient?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadData() async {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
final authManager = context.read<AuthenticationManager>();
|
|
||||||
_devices = await authManager.getDevices();
|
|
||||||
final radioIds = _devices.map((d) => d.id).toList();
|
|
||||||
|
|
||||||
if (radioIds.isEmpty) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_wsClient = MeActivityWebSocketClient(
|
|
||||||
radioIds: radioIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe first (stream controller is created in constructor now)
|
|
||||||
_wsSubscription = _wsClient!.messageStream.listen((message) {
|
|
||||||
debugPrint('MeView: Received message: $message');
|
|
||||||
final event = message['event'];
|
|
||||||
final data = message['data'];
|
|
||||||
|
|
||||||
// Handle both 'mqtt' events and direct search results
|
|
||||||
if (event == 'mqtt' && data is Map) {
|
|
||||||
_handleMqttMessage(data as Map<String, dynamic>);
|
|
||||||
} else if (data is Map) {
|
|
||||||
// Search results may come directly without mqtt wrapper
|
|
||||||
_handleDirectResult(data as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then connect
|
|
||||||
await _wsClient!.connect();
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleDirectResult(Map<String, dynamic> data) {
|
|
||||||
try {
|
|
||||||
// Direct search result - data is already the LastHeardItem JSON
|
|
||||||
final item = LastHeardItem.fromJson(data);
|
|
||||||
|
|
||||||
// Filter out items without a callsign
|
|
||||||
if (item.sourceCall.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find which device this item belongs to (by ContextID matching device id)
|
|
||||||
final deviceId = item.contextID;
|
|
||||||
|
|
||||||
// Only process if this is one of the user's devices
|
|
||||||
final deviceIds = _devices.map((d) => d.id).toSet();
|
|
||||||
if (!deviceIds.contains(deviceId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
if (!_itemsByDevice.containsKey(deviceId)) {
|
|
||||||
_itemsByDevice[deviceId] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
final deviceItems = _itemsByDevice[deviceId]!;
|
|
||||||
|
|
||||||
// Only add if we haven't reached max items for this device
|
|
||||||
if (deviceItems.length < _maxItemsPerDevice) {
|
|
||||||
deviceItems.add(item);
|
|
||||||
// Sort by timestamp (most recent first)
|
|
||||||
deviceItems.sort((a, b) => b.start.compareTo(a.start));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error handling direct result: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleMqttMessage(Map<String, dynamic> data) {
|
|
||||||
try {
|
|
||||||
final payloadStr = data['payload'] as String?;
|
|
||||||
if (payloadStr != null) {
|
|
||||||
final payload = jsonDecode(payloadStr) as Map<String, dynamic>;
|
|
||||||
final item = LastHeardItem.fromJson(payload);
|
|
||||||
|
|
||||||
// Filter out items without a callsign
|
|
||||||
if (item.sourceCall.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find which device this item belongs to (by ContextID matching device id)
|
|
||||||
final deviceId = item.contextID;
|
|
||||||
|
|
||||||
// Only process if this is one of the user's devices
|
|
||||||
final deviceIds = _devices.map((d) => d.id).toSet();
|
|
||||||
if (!deviceIds.contains(deviceId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
if (!_itemsByDevice.containsKey(deviceId)) {
|
|
||||||
_itemsByDevice[deviceId] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
final deviceItems = _itemsByDevice[deviceId]!;
|
|
||||||
|
|
||||||
// Add to the beginning (most recent first)
|
|
||||||
deviceItems.insert(0, item);
|
|
||||||
|
|
||||||
// Keep only the latest items per device
|
|
||||||
if (deviceItems.length > _maxItemsPerDevice) {
|
|
||||||
deviceItems.removeRange(_maxItemsPerDevice, deviceItems.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error handling MQTT message: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final authManager = context.watch<AuthenticationManager>();
|
|
||||||
final userInfo = authManager.userInfo;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Me'),
|
|
||||||
),
|
|
||||||
body: ListView(
|
|
||||||
children: [
|
|
||||||
UserHeader(
|
|
||||||
userInfo: userInfo,
|
|
||||||
radioId: _devices.isNotEmpty ? _devices.first.id.toString() : null,
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
_buildActivitySection(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActivitySection() {
|
|
||||||
if (_isLoading) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.all(32),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
Text('Loading activity...'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_devices.isEmpty) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.radio, size: 48, color: Colors.grey[400]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No devices found',
|
|
||||||
style: TextStyle(color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
||||||
child: Text(
|
|
||||||
'Recent Activity',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
..._devices.map((device) => _buildDeviceActivitySection(device)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDeviceActivitySection(Device device) {
|
|
||||||
final items = _itemsByDevice[device.id] ?? [];
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.radio,
|
|
||||||
size: 18,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
device.id.toString(),
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (items.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Text(
|
|
||||||
'No recent activity',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey[500],
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
...items.map((item) => _ActivityItemTile(item: item)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ActivityItemTile extends StatelessWidget {
|
|
||||||
final LastHeardItem item;
|
|
||||||
|
|
||||||
const _ActivityItemTile({required this.item});
|
|
||||||
|
|
||||||
Color _getEventColor() {
|
|
||||||
switch (item.event) {
|
|
||||||
case 'Session-Start':
|
|
||||||
return Colors.green;
|
|
||||||
case 'Session-Stop':
|
|
||||||
return Colors.red;
|
|
||||||
default:
|
|
||||||
return Colors.blue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getEventIcon() {
|
|
||||||
switch (item.event) {
|
|
||||||
case 'Session-Start':
|
|
||||||
return Icons.phone_in_talk;
|
|
||||||
case 'Session-Stop':
|
|
||||||
return Icons.call_end;
|
|
||||||
default:
|
|
||||||
return Icons.radio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final eventColor = _getEventColor();
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: CircleAvatar(
|
|
||||||
radius: 16,
|
|
||||||
backgroundColor: eventColor.withValues(alpha: 0.2),
|
|
||||||
child: Icon(
|
|
||||||
_getEventIcon(),
|
|
||||||
color: eventColor,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
item.displayName,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
|
|
||||||
),
|
|
||||||
subtitle: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.arrow_forward, size: 10, color: Colors.grey[600]),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
item.destinationDisplayName,
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: eventColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'TS${item.slot}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 9,
|
|
||||||
color: eventColor,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
item.timeAgo,
|
|
||||||
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../services/authentication_manager.dart';
|
import '../services/authentication_manager.dart';
|
||||||
import 'hose_view.dart';
|
|
||||||
|
|
||||||
class MoreView extends StatelessWidget {
|
class MoreView extends StatelessWidget {
|
||||||
const MoreView({super.key});
|
const MoreView({super.key});
|
||||||
@@ -12,60 +12,20 @@ class MoreView extends StatelessWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('More'),
|
title: const Text('Settings'),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
_buildFeaturesSection(context),
|
|
||||||
const Divider(height: 1),
|
|
||||||
_buildAppInfoSection(context),
|
_buildAppInfoSection(context),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
_buildLegalSection(context),
|
||||||
|
const Divider(height: 1),
|
||||||
_buildLogoutSection(context, authManager),
|
_buildLogoutSection(context, authManager),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFeaturesSection(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Features',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Card(
|
|
||||||
child: ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
child: Icon(
|
|
||||||
Icons.water_drop,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: const Text('Hose'),
|
|
||||||
subtitle: const Text('Live talkgroup activity monitor'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const HoseView(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAppInfoSection(BuildContext context) {
|
Widget _buildAppInfoSection(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -87,7 +47,51 @@ class MoreView extends StatelessWidget {
|
|||||||
const _InfoRow(
|
const _InfoRow(
|
||||||
icon: Icons.copyright,
|
icon: Icons.copyright,
|
||||||
label: 'Copyright',
|
label: 'Copyright',
|
||||||
value: '2026',
|
value: 'Bearologics GmbH',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLegalSection(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Legal',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(
|
||||||
|
Icons.gavel,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: const Text('Impressum'),
|
||||||
|
trailing: const Icon(Icons.open_in_new, size: 18),
|
||||||
|
onTap: () {
|
||||||
|
final url = Uri.parse('https://bearologics.com/impressum');
|
||||||
|
launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(
|
||||||
|
Icons.privacy_tip_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: const Text('Privacy Policy'),
|
||||||
|
trailing: const Icon(Icons.open_in_new, size: 18),
|
||||||
|
onTap: () {
|
||||||
|
final url = Uri.parse('https://bearologics.com/privacy');
|
||||||
|
launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class WelcomeView extends StatelessWidget {
|
|
||||||
const WelcomeView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.radio,
|
|
||||||
size: 100,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
'Manage your BrandMeister devices',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 48),
|
|
||||||
const CircularProgressIndicator(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
|
|||||||
|
|
||||||
# The name of the executable created for the application. Change this to change
|
# The name of the executable created for the application. Change this to change
|
||||||
# the on-disk name of your application.
|
# the on-disk name of your application.
|
||||||
set(BINARY_NAME "bmmanager")
|
set(BINARY_NAME "brandmanager")
|
||||||
# The unique GTK application identifier for this application. See:
|
# The unique GTK application identifier for this application. See:
|
||||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||||
set(APPLICATION_ID "com.example.bmmanager")
|
set(APPLICATION_ID "gmbh.bearologics.BrandManager")
|
||||||
|
|
||||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||||
# versions of CMake.
|
# versions of CMake.
|
||||||
|
|||||||
@@ -7,9 +7,13 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,7 +385,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bmmanager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bmmanager";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bmmanager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bmmanager";
|
||||||
@@ -399,7 +399,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bmmanager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bmmanager";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bmmanager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bmmanager";
|
||||||
@@ -413,7 +413,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bmmanager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bmmanager";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bmmanager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bmmanager";
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
// 'flutter create' template.
|
// 'flutter create' template.
|
||||||
|
|
||||||
// The application's name. By default this is also the title of the Flutter window.
|
// The application's name. By default this is also the title of the Flutter window.
|
||||||
PRODUCT_NAME = bmmanager
|
PRODUCT_NAME = BrandManager
|
||||||
|
|
||||||
// The application's bundle identifier
|
// The application's bundle identifier
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager
|
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager
|
||||||
|
|
||||||
// The copyright displayed in application information
|
// The copyright displayed in application information
|
||||||
PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.
|
PRODUCT_COPYRIGHT = Copyright © 2026 Bearologics GmbH. All rights reserved.
|
||||||
|
|||||||
64
pubspec.lock
64
pubspec.lock
@@ -509,6 +509,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
url_launcher:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: url_launcher
|
||||||
|
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.2"
|
||||||
|
url_launcher_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_android
|
||||||
|
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.28"
|
||||||
|
url_launcher_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_ios
|
||||||
|
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.6"
|
||||||
|
url_launcher_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_linux
|
||||||
|
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
|
url_launcher_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_macos
|
||||||
|
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.5"
|
||||||
|
url_launcher_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_platform_interface
|
||||||
|
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
|
url_launcher_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_web
|
||||||
|
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
url_launcher_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_windows
|
||||||
|
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.5"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ dependencies:
|
|||||||
# WebSocket client for real-time updates
|
# WebSocket client for real-time updates
|
||||||
web_socket_channel: ^3.0.1
|
web_socket_channel: ^3.0.1
|
||||||
|
|
||||||
|
# URL launcher for opening external links
|
||||||
|
url_launcher: ^6.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@@ -80,10 +83,8 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
assets:
|
||||||
# assets:
|
- assets/icon/
|
||||||
# - images/a_dot_burr.jpeg
|
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -89,13 +89,13 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
BLOCK "040904e4"
|
BLOCK "040904e4"
|
||||||
BEGIN
|
BEGIN
|
||||||
VALUE "CompanyName", "com.example" "\0"
|
VALUE "CompanyName", "Bearologics GmbH" "\0"
|
||||||
VALUE "FileDescription", "bmmanager" "\0"
|
VALUE "FileDescription", "BrandManager" "\0"
|
||||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||||
VALUE "InternalName", "bmmanager" "\0"
|
VALUE "InternalName", "BrandManager" "\0"
|
||||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
|
VALUE "LegalCopyright", "Copyright (C) 2026 Bearologics GmbH. All rights reserved." "\0"
|
||||||
VALUE "OriginalFilename", "bmmanager.exe" "\0"
|
VALUE "OriginalFilename", "BrandManager.exe" "\0"
|
||||||
VALUE "ProductName", "bmmanager" "\0"
|
VALUE "ProductName", "BrandManager" "\0"
|
||||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
|
|||||||
Reference in New Issue
Block a user