Compare commits

...

10 Commits

Author SHA1 Message Date
87c9a74b97 Add README and LICENSE 2026-02-01 23:23:17 +01:00
1e9a82a714 Polish views 2026-02-01 23:15:24 +01:00
d87f00037a Rename Package 2026-02-01 22:54:53 +01:00
83a8a3aaef Add imopressum 2026-02-01 22:45:35 +01:00
9fd0c210ae Improve Login and Settings 2026-02-01 22:42:00 +01:00
9dc86419e4 Remove Hose 2026-01-29 08:52:32 +01:00
18b70de638 Improve last activity 2026-01-29 08:48:58 +01:00
ef59bf5011 Remove MeView 2026-01-29 08:25:01 +01:00
a510d1f299 Use antenna icon 2026-01-25 16:53:41 +01:00
7774dc8514 Update colors 2026-01-25 16:50:31 +01:00
35 changed files with 410 additions and 1276 deletions

BIN
.meta/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

BIN
.meta/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
.meta/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
.meta/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

23
LICENSE.md Normal file
View 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.

View File

@@ -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
| | | | |
|:---:|:---:|:---:|:---:|
| ![Screenshot 1](.meta/1.png) | ![Screenshot 2](.meta/2.png) | ![Screenshot 3](.meta/3.png) | ![Screenshot 4](.meta/4.png) |
## 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)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
### Installation
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.
1. Clone the repository:
```bash
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)

View File

@@ -6,7 +6,7 @@ plugins {
}
android {
namespace = "com.example.bmmanager"
namespace = "gmbh.bearologics.brandmanager"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -20,8 +20,7 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.bmmanager"
applicationId = "gmbh.bearologics.brandmanager"
// 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

View File

@@ -1,4 +1,4 @@
package com.example.bmmanager
package gmbh.bearologics.brandmanager
import io.flutter.embedding.android.FlutterActivity

3
devtools_options.yaml Normal file
View 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:

View File

@@ -369,7 +369,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager;
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -385,7 +385,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -402,7 +402,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -417,7 +417,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -549,7 +549,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager;
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -572,7 +572,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager;
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@@ -19,14 +19,14 @@ class BrandManagerApp extends StatelessWidget {
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
seedColor: const Color(0xFFa1181d),
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
seedColor: const Color(0xFFa1181d),
brightness: Brightness.dark,
),
useMaterial3: true,

View File

@@ -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,
);
}
}

View File

@@ -119,9 +119,21 @@ class LastHeardItem {
String get destinationDisplayName {
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 {

View File

@@ -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();
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/authentication_manager.dart';
import '../services/brandmeister_client.dart';
@@ -69,28 +70,72 @@ class _AuthViewState extends State<AuthView> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.radio,
size: 80,
color: Theme.of(context).colorScheme.primary,
Center(
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
image: const DecorationImage(
image: AssetImage('assets/icon/app_icon.png'),
fit: BoxFit.cover,
),
),
),
),
const SizedBox(height: 24),
Text(
'Sign In',
'BrandManager',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const SizedBox(height: 4),
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(
color: Colors.grey[600],
),
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(
controller: _tokenController,
focusNode: _focusNode,
@@ -133,7 +178,8 @@ class _AuthViewState extends State<AuthView> {
const SizedBox(height: 24),
TextButton.icon(
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),
label: const Text('Get API Token from BrandMeister'),

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/authentication_manager.dart';
import 'welcome_view.dart';
import 'auth_view.dart';
import 'main_view.dart';
@@ -12,9 +11,13 @@ class ContentView extends StatelessWidget {
Widget build(BuildContext context) {
final authManager = context.watch<AuthenticationManager>();
// Show splash screen while initializing
// Show loading indicator while initializing
if (authManager.isInitializing) {
return const WelcomeView();
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
// Show main view if authenticated, otherwise show login

View File

@@ -626,7 +626,7 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
_buildTalkgroupCategory(
'Static',
_deviceProfile!.staticSubscriptions,
Colors.blue,
Theme.of(context).colorScheme.primary,
Icons.link,
canDelete: true,
),

View File

@@ -143,6 +143,7 @@ class _DevicesViewState extends State<DevicesView> {
const Divider(),
..._devices.map((device) => _DeviceRow(
device: device,
baseRadioId: _devices.isNotEmpty ? _devices.first.id : null,
onTap: () {
Navigator.push(
context,
@@ -159,20 +160,44 @@ class _DevicesViewState extends State<DevicesView> {
class _DeviceRow extends StatelessWidget {
final Device device;
final int? baseRadioId;
final VoidCallback onTap;
const _DeviceRow({
required this.device,
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
Widget build(BuildContext context) {
final extension = _deviceExtension;
final showExtension = extension != null && extension != '0';
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.radio,
child: showExtension
? Text(
extension,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
)
: Icon(
Icons.settings_input_antenna,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),

View File

@@ -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,
),
),
],
),
],
),
],
),
),
),
);
}
}

View File

@@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/last_heard_item.dart';
import '../models/user_info.dart';
import '../services/authentication_manager.dart';
import '../services/lastheard_websocket_client.dart';
import '../widgets/user_header.dart';
class LastHeardView extends StatefulWidget {
const LastHeardView({super.key});
@@ -19,6 +21,8 @@ class _LastHeardViewState extends State<LastHeardView> {
final List<LastHeardItem> _items = [];
static const int _maxItems = 200;
bool _isConnecting = true;
UserInfo? _userInfo;
String? _firstRadioId;
@override
void initState() {
@@ -39,6 +43,7 @@ class _LastHeardViewState extends State<LastHeardView> {
});
final authManager = context.read<AuthenticationManager>();
final userInfo = authManager.userInfo;
final devices = await authManager.getDevices();
final radioIds = devices.map((d) => d.id).toList();
@@ -52,6 +57,8 @@ class _LastHeardViewState extends State<LastHeardView> {
});
setState(() {
_userInfo = userInfo;
_firstRadioId = devices.isNotEmpty ? devices.first.id.toString() : null;
_isConnecting = false;
});
}
@@ -71,8 +78,10 @@ class _LastHeardViewState extends State<LastHeardView> {
}
setState(() {
// Add to the beginning of the list
_items.insert(0, item);
_items.add(item);
// Sort by timestamp, most recent first
_items.sort((a, b) => b.start.compareTo(a.start));
// Keep only the latest 200 items
if (_items.length > _maxItems) {
@@ -136,9 +145,12 @@ class _LastHeardViewState extends State<LastHeardView> {
}
return ListView.builder(
itemCount: _items.length,
itemCount: _items.length + 1,
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);
},
);
@@ -150,123 +162,51 @@ class _LastHeardItemTile extends StatelessWidget {
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
Widget build(BuildContext context) {
final eventColor = _getEventColor();
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: eventColor.withValues(alpha: 0.2),
child: Icon(
_getEventIcon(),
color: eventColor,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.arrow_forward,
color: Colors.green,
size: 20,
),
),
title: Row(
children: [
const SizedBox(width: 12),
Expanded(
child: Text(
item.displayName,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Text(
item.destinationDisplayName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
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],
),
),
if (item.durationDisplay.isNotEmpty) ...[
const SizedBox(width: 8),
Text(
item.durationDisplay,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
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),
const Spacer(),
Text(
item.timeAgo,
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
],
),
isThreeLine: true,
),
],
),
),
);
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'me_view.dart';
import 'devices_view.dart';
import 'last_heard_view.dart';
import 'more_view.dart';
@@ -15,7 +14,6 @@ class _MainViewState extends State<MainView> {
int _selectedIndex = 0;
final List<Widget> _views = const [
MeView(),
DevicesView(),
LastHeardView(),
MoreView(),
@@ -30,10 +28,6 @@ class _MainViewState extends State<MainView> {
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Me',
),
BottomNavigationBarItem(
icon: Icon(Icons.devices),
label: 'Devices',
@@ -43,8 +37,8 @@ class _MainViewState extends State<MainView> {
label: 'Last Activity',
),
BottomNavigationBarItem(
icon: Icon(Icons.more_horiz),
label: 'More',
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),

View File

@@ -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]),
),
],
),
);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/authentication_manager.dart';
import 'hose_view.dart';
class MoreView extends StatelessWidget {
const MoreView({super.key});
@@ -12,60 +12,20 @@ class MoreView extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: const Text('More'),
title: const Text('Settings'),
),
body: ListView(
children: [
_buildFeaturesSection(context),
const Divider(height: 1),
_buildAppInfoSection(context),
const Divider(height: 1),
_buildLegalSection(context),
const Divider(height: 1),
_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) {
return Container(
padding: const EdgeInsets.all(16),
@@ -87,7 +47,51 @@ class MoreView extends StatelessWidget {
const _InfoRow(
icon: Icons.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);
},
),
],
),

View File

@@ -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(),
],
),
),
);
}
}

View File

@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "bmmanager")
set(BINARY_NAME "brandmanager")
# The unique GTK application identifier for this application. See:
# 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
# versions of CMake.

View File

@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.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) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
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);
}

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -6,7 +6,9 @@ import FlutterMacOS
import Foundation
import flutter_secure_storage_macos
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -385,7 +385,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bmmanager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bmmanager";
@@ -399,7 +399,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bmmanager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bmmanager";
@@ -413,7 +413,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bmmanager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bmmanager";

View File

@@ -5,10 +5,10 @@
// 'flutter create' template.
// 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
PRODUCT_BUNDLE_IDENTIFIER = com.example.bmmanager
PRODUCT_BUNDLE_IDENTIFIER = gmbh.bearologics.BrandManager
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.
PRODUCT_COPYRIGHT = Copyright © 2026 Bearologics GmbH. All rights reserved.

View File

@@ -509,6 +509,70 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:

View File

@@ -47,6 +47,9 @@ dependencies:
# WebSocket client for real-time updates
web_socket_channel: ^3.0.1
# URL launcher for opening external links
url_launcher: ^6.2.0
dev_dependencies:
flutter_test:
sdk: flutter
@@ -80,10 +83,8 @@ flutter:
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/icon/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

View File

@@ -7,8 +7,11 @@
#include "generated_plugin_registrant.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) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -89,13 +89,13 @@ BEGIN
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.example" "\0"
VALUE "FileDescription", "bmmanager" "\0"
VALUE "CompanyName", "Bearologics GmbH" "\0"
VALUE "FileDescription", "BrandManager" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "bmmanager" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
VALUE "OriginalFilename", "bmmanager.exe" "\0"
VALUE "ProductName", "bmmanager" "\0"
VALUE "InternalName", "BrandManager" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 Bearologics GmbH. All rights reserved." "\0"
VALUE "OriginalFilename", "BrandManager.exe" "\0"
VALUE "ProductName", "BrandManager" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END