Initial commit

This commit is contained in:
2026-01-19 10:20:45 +01:00
commit dd6d0b6e7b
144 changed files with 7016 additions and 0 deletions

38
lib/main.dart Normal file
View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'services/authentication_manager.dart';
import 'views/content_view.dart';
void main() {
runApp(const BmManagerApp());
}
class BmManagerApp extends StatelessWidget {
const BmManagerApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AuthenticationManager(),
child: MaterialApp(
title: 'BM Manager',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const ContentView(),
),
);
}
}

35
lib/models/cluster.dart Normal file
View File

@@ -0,0 +1,35 @@
class Cluster {
final int? id;
final int? masterId;
final String? clusterName;
final int? talkgroup;
final bool? repeatersOnly;
Cluster({
this.id,
this.masterId,
this.clusterName,
this.talkgroup,
this.repeatersOnly,
});
factory Cluster.fromJson(Map<String, dynamic> json) {
return Cluster(
id: json['id'] as int?,
masterId: json['masterId'] as int?,
clusterName: json['clusterName'] as String?,
talkgroup: json['talkgroup'] as int?,
repeatersOnly: json['repeatersOnly'] as bool?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'masterId': masterId,
'clusterName': clusterName,
'talkgroup': talkgroup,
'repeatersOnly': repeatersOnly,
};
}
}

129
lib/models/device.dart Normal file
View File

@@ -0,0 +1,129 @@
class Device {
final int id;
final String? callsign;
final String? name;
final String? city;
final String? state;
final String? country;
final String? surname;
final String? linkname;
final String? hardware;
final String? firmware;
final String? tx;
final String? rx;
final int? colorcode;
final int? status;
final int? lastKnownMaster;
final double? lat;
final double? lng;
final String? website;
final int? pep;
final int? agl;
final String? description;
final String? createdAt;
final String? updatedAt;
final String? lastSeen;
final List<String>? permissions;
Device({
required this.id,
this.callsign,
this.name,
this.city,
this.state,
this.country,
this.surname,
this.linkname,
this.hardware,
this.firmware,
this.tx,
this.rx,
this.colorcode,
this.status,
this.lastKnownMaster,
this.lat,
this.lng,
this.website,
this.pep,
this.agl,
this.description,
this.createdAt,
this.updatedAt,
this.lastSeen,
this.permissions,
});
factory Device.fromJson(Map<String, dynamic> json) {
return Device(
id: json['id'] as int,
callsign: json['callsign'] as String?,
name: json['name'] as String?,
city: json['city'] as String?,
state: json['state'] as String?,
country: json['country'] as String?,
surname: json['surname'] as String?,
linkname: json['linkname'] as String?,
hardware: json['hardware'] as String?,
firmware: json['firmware'] as String?,
tx: json['tx'] as String?,
rx: json['rx'] as String?,
colorcode: json['colorcode'] as int?,
status: json['status'] as int?,
lastKnownMaster: json['last_known_master'] as int?,
lat: json['lat'] as double?,
lng: json['lng'] as double?,
website: json['website'] as String?,
pep: json['pep'] as int?,
agl: json['agl'] as int?,
description: json['description'] as String?,
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
lastSeen: json['last_seen'] as String?,
permissions: (json['permissions'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'callsign': callsign,
'name': name,
'city': city,
'state': state,
'country': country,
'surname': surname,
'linkname': linkname,
'hardware': hardware,
'firmware': firmware,
'tx': tx,
'rx': rx,
'colorcode': colorcode,
'status': status,
'last_known_master': lastKnownMaster,
'lat': lat,
'lng': lng,
'website': website,
'pep': pep,
'agl': agl,
'description': description,
'created_at': createdAt,
'updated_at': updatedAt,
'last_seen': lastSeen,
'permissions': permissions,
};
}
String get displayLocation {
final parts = <String>[];
if (city != null && city!.isNotEmpty) parts.add(city!);
if (state != null && state!.isNotEmpty) parts.add(state!);
if (country != null && country!.isNotEmpty) parts.add(country!);
return parts.isEmpty ? 'Unknown Location' : parts.join(', ');
}
String get displayName {
return name ?? callsign ?? 'Unknown Device';
}
}

View File

@@ -0,0 +1,54 @@
import 'static_talkgroup.dart';
import 'cluster.dart';
class DeviceProfile {
final List<StaticTalkgroup> blockedGroups;
final List<StaticTalkgroup> staticSubscriptions;
final List<StaticTalkgroup> dynamicSubscriptions;
final List<StaticTalkgroup> timedSubscriptions;
final List<Cluster> clusters;
DeviceProfile({
this.blockedGroups = const [],
this.staticSubscriptions = const [],
this.dynamicSubscriptions = const [],
this.timedSubscriptions = const [],
this.clusters = const [],
});
factory DeviceProfile.fromJson(Map<String, dynamic> json) {
return DeviceProfile(
blockedGroups: (json['blockedGroups'] as List<dynamic>?)
?.map((e) => StaticTalkgroup.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
staticSubscriptions: (json['staticSubscriptions'] as List<dynamic>?)
?.map((e) => StaticTalkgroup.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
dynamicSubscriptions: (json['dynamicSubscriptions'] as List<dynamic>?)
?.map((e) => StaticTalkgroup.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
timedSubscriptions: (json['timedSubscriptions'] as List<dynamic>?)
?.map((e) => StaticTalkgroup.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
clusters: (json['clusters'] as List<dynamic>?)
?.map((e) => Cluster.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'blockedGroups': blockedGroups.map((e) => e.toJson()).toList(),
'staticSubscriptions': staticSubscriptions.map((e) => e.toJson()).toList(),
'dynamicSubscriptions':
dynamicSubscriptions.map((e) => e.toJson()).toList(),
'timedSubscriptions': timedSubscriptions.map((e) => e.toJson()).toList(),
'clusters': clusters.map((e) => e.toJson()).toList(),
};
}
}

View File

@@ -0,0 +1,35 @@
class StaticTalkgroup {
final String? talkgroup;
final String? repeaterId;
final String? slot;
StaticTalkgroup({
this.talkgroup,
this.repeaterId,
this.slot,
});
factory StaticTalkgroup.fromJson(Map<String, dynamic> json) {
return StaticTalkgroup(
talkgroup: json['talkgroup']?.toString(),
repeaterId: json['repeaterId']?.toString(),
slot: json['slot']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'talkgroup': talkgroup,
'repeaterId': repeaterId,
'slot': slot,
};
}
String get displaySlot {
return slot != null ? 'TS$slot' : 'TS?';
}
String get displayId {
return talkgroup ?? 'Unknown';
}
}

37
lib/models/user_info.dart Normal file
View File

@@ -0,0 +1,37 @@
class UserInfo {
final int id;
final String? name;
final String? username;
UserInfo({
required this.id,
this.name,
this.username,
});
factory UserInfo.fromJson(Map<String, dynamic> json) {
return UserInfo(
id: json['id'] as int,
name: json['name'] as String?,
username: json['username'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'username': username,
};
}
String get displayName {
if (username != null && username!.isNotEmpty) {
if (name != null && name!.isNotEmpty) {
return '$username ($name)';
}
return username!;
}
return name ?? 'User $id';
}
}

View File

@@ -0,0 +1,189 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../models/device.dart';
import '../models/user_info.dart';
import '../models/static_talkgroup.dart';
import '../models/device_profile.dart';
import 'brandmeister_client.dart';
class AuthenticationManager extends ChangeNotifier {
static const String _tokenKey = 'apiToken';
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock,
),
);
bool _isAuthenticated = false;
String _apiToken = '';
UserInfo? _userInfo;
BrandmeisterClient? _client;
bool get isAuthenticated => _isAuthenticated;
String get apiToken => _apiToken;
UserInfo? get userInfo => _userInfo;
AuthenticationManager() {
_loadToken();
}
Future<void> _loadToken() async {
try {
final token = await _storage.read(key: _tokenKey);
if (token != null && token.isNotEmpty) {
_apiToken = token;
_client = BrandmeisterClient(apiToken: token);
// Try to verify the token is still valid
try {
_userInfo = await _client!.whoami();
_isAuthenticated = true;
notifyListeners();
} catch (e) {
// Token is invalid, clear it
await logout();
}
}
} catch (e) {
debugPrint('Error loading token: $e');
}
}
Future<void> _saveToken(String token) async {
try {
await _storage.write(key: _tokenKey, value: token);
_apiToken = token;
_client = BrandmeisterClient(apiToken: token);
} catch (e) {
throw BrandmeisterError('Failed to save token: $e');
}
}
Future<UserInfo> verifyAndSaveToken(String token) async {
if (token.isEmpty) {
throw BrandmeisterError('Token cannot be empty');
}
final tempClient = BrandmeisterClient(apiToken: token);
final user = await tempClient.whoami();
await _saveToken(token);
_userInfo = user;
_isAuthenticated = true;
notifyListeners();
return user;
}
Future<void> logout() async {
try {
await _storage.delete(key: _tokenKey);
_apiToken = '';
_userInfo = null;
_isAuthenticated = false;
_client = null;
notifyListeners();
} catch (e) {
debugPrint('Error during logout: $e');
}
}
Future<void> refreshUserInfo() async {
if (_client == null) return;
try {
_userInfo = await _client!.whoami();
notifyListeners();
} catch (e) {
debugPrint('Error refreshing user info: $e');
rethrow;
}
}
// Device Operations
Future<List<Device>> getDevices() async {
if (_client == null || _userInfo == null) {
throw BrandmeisterError('Not authenticated');
}
if (_userInfo!.username == null) {
throw BrandmeisterError('Username not available');
}
return await _client!.getDevicesByCallsign(_userInfo!.username!);
}
Future<Device> getDevice(int dmrId) async {
if (_client == null) {
throw BrandmeisterError('Not authenticated');
}
return await _client!.getDevice(dmrId);
}
Future<DeviceProfile> getDeviceProfile(int dmrId) async {
if (_client == null) {
throw BrandmeisterError('Not authenticated');
}
return await _client!.getDeviceProfile(dmrId);
}
Future<List<StaticTalkgroup>> getTalkgroups(int dmrId) async {
if (_client == null) {
throw BrandmeisterError('Not authenticated');
}
return await _client!.getDeviceTalkgroups(dmrId);
}
Future<Map<String, String>> getAllTalkgroups() async {
if (_client == null) {
throw BrandmeisterError('Not authenticated');
}
return await _client!.getTalkgroups();
}
Future<void> linkTalkgroup({
required int talkgroupId,
required int dmrId,
required int timeslot,
}) async {
if (_client == null) {
throw BrandmeisterError('Not authenticated');
}
await _client!.addTalkgroup(
dmrId: dmrId,
talkgroupId: talkgroupId,
slot: timeslot,
);
}
Future<void> unlinkTalkgroup({
required String talkgroupId,
required int dmrId,
required String timeslot,
}) async {
if (_client == null) {
throw BrandmeisterError('Not authenticated');
}
final tgId = int.tryParse(talkgroupId);
final slot = int.tryParse(timeslot);
if (tgId == null || slot == null) {
throw BrandmeisterError('Invalid talkgroup ID or timeslot');
}
await _client!.removeTalkgroup(
dmrId: dmrId,
talkgroupId: tgId,
slot: slot,
);
}
}

View File

@@ -0,0 +1,186 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/device.dart';
import '../models/user_info.dart';
import '../models/static_talkgroup.dart';
import '../models/device_profile.dart';
class BrandmeisterError implements Exception {
final String message;
final int? statusCode;
BrandmeisterError(this.message, {this.statusCode});
@override
String toString() {
if (statusCode != null) {
return 'BrandmeisterError (HTTP $statusCode): $message';
}
return 'BrandmeisterError: $message';
}
}
class BrandmeisterClient {
static const String baseUrl = 'https://api.brandmeister.network/v2';
final String apiToken;
BrandmeisterClient({required this.apiToken});
Map<String, String> get _headers => {
'Authorization': 'Bearer $apiToken',
'Content-Type': 'application/json',
};
Future<T> _handleResponse<T>(
http.Response response,
T Function(dynamic) parser,
) async {
if (response.statusCode >= 200 && response.statusCode < 300) {
try {
final dynamic decoded = jsonDecode(response.body);
return parser(decoded);
} catch (e) {
throw BrandmeisterError('Failed to decode response: $e');
}
} else {
throw BrandmeisterError(
'HTTP request failed: ${response.body}',
statusCode: response.statusCode,
);
}
}
// User Endpoints
Future<UserInfo> whoami() async {
final response = await http.post(
Uri.parse('$baseUrl/user/whoAmI'),
headers: _headers,
);
return _handleResponse(response, (json) => UserInfo.fromJson(json));
}
Future<UserInfo> getUser(int id) async {
final response = await http.get(
Uri.parse('$baseUrl/user/byID/$id'),
headers: _headers,
);
return _handleResponse(response, (json) => UserInfo.fromJson(json));
}
Future<List<UserInfo>> getUsersByCallsign(String callsign) async {
final response = await http.get(
Uri.parse('$baseUrl/user/byCall/$callsign'),
headers: _headers,
);
return _handleResponse(
response,
(json) => (json as List).map((e) => UserInfo.fromJson(e)).toList(),
);
}
// Device Endpoints
Future<Device> getDevice(int dmrId) async {
final response = await http.get(
Uri.parse('$baseUrl/device/$dmrId'),
headers: _headers,
);
return _handleResponse(response, (json) => Device.fromJson(json));
}
Future<DeviceProfile> getDeviceProfile(int dmrId) async {
final response = await http.get(
Uri.parse('$baseUrl/device/$dmrId/profile'),
headers: _headers,
);
return _handleResponse(response, (json) => DeviceProfile.fromJson(json));
}
Future<List<Device>> getDevicesByCallsign(String callsign) async {
final response = await http.get(
Uri.parse('$baseUrl/device/byCall?callsign=$callsign'),
headers: _headers,
);
return _handleResponse(
response,
(json) => (json as List).map((e) => Device.fromJson(e)).toList(),
);
}
Future<List<Device>> getDevicesByMaster(int masterId) async {
final response = await http.get(
Uri.parse('$baseUrl/device/byMaster/$masterId'),
headers: _headers,
);
return _handleResponse(
response,
(json) => (json as List).map((e) => Device.fromJson(e)).toList(),
);
}
Future<List<StaticTalkgroup>> getDeviceTalkgroups(int dmrId) async {
final response = await http.get(
Uri.parse('$baseUrl/device/$dmrId/talkgroup'),
headers: _headers,
);
return _handleResponse(
response,
(json) =>
(json as List).map((e) => StaticTalkgroup.fromJson(e)).toList(),
);
}
// Talkgroup Management
Future<void> addTalkgroup({
required int dmrId,
required int talkgroupId,
required int slot,
}) async {
final response = await http.post(
Uri.parse('$baseUrl/device/$dmrId/talkgroup'),
headers: _headers,
body: jsonEncode({
'slot': slot,
'group': talkgroupId,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw BrandmeisterError(
'Failed to add talkgroup: ${response.body}',
statusCode: response.statusCode,
);
}
}
Future<void> removeTalkgroup({
required int dmrId,
required int talkgroupId,
required int slot,
}) async {
final response = await http.delete(
Uri.parse('$baseUrl/device/$dmrId/talkgroup/$slot/$talkgroupId'),
headers: _headers,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw BrandmeisterError(
'Failed to remove talkgroup: ${response.body}',
statusCode: response.statusCode,
);
}
}
// Talkgroup Endpoints
Future<Map<String, String>> getTalkgroups() async {
final response = await http.get(
Uri.parse('$baseUrl/talkgroup'),
headers: _headers,
);
return _handleResponse(
response,
(json) => (json as Map<String, dynamic>).map(
(key, value) => MapEntry(key, value.toString()),
),
);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/authentication_manager.dart';
import 'welcome_view.dart';
import 'main_view.dart';
class ContentView extends StatelessWidget {
const ContentView({super.key});
@override
Widget build(BuildContext context) {
final authManager = context.watch<AuthenticationManager>();
if (authManager.isAuthenticated) {
return const MainView();
} else {
return const WelcomeView();
}
}
}

View File

@@ -0,0 +1,391 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/device.dart';
import '../models/static_talkgroup.dart';
import '../services/authentication_manager.dart';
import '../services/brandmeister_client.dart';
import 'link_talkgroup_view.dart';
class DeviceDetailView extends StatefulWidget {
final Device device;
const DeviceDetailView({super.key, required this.device});
@override
State<DeviceDetailView> createState() => _DeviceDetailViewState();
}
class _DeviceDetailViewState extends State<DeviceDetailView> {
List<StaticTalkgroup> _talkgroups = [];
Map<String, String> _allTalkgroups = {};
bool _isLoadingTalkgroups = false;
bool _isLoadingDeviceDetails = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_isLoadingTalkgroups = true;
_isLoadingDeviceDetails = true;
_errorMessage = null;
});
try {
final authManager = context.read<AuthenticationManager>();
final results = await Future.wait([
authManager.getTalkgroups(widget.device.id),
authManager.getAllTalkgroups(),
]);
setState(() {
_talkgroups = results[0] as List<StaticTalkgroup>;
_allTalkgroups = results[1] as Map<String, String>;
_isLoadingTalkgroups = false;
_isLoadingDeviceDetails = false;
});
} on BrandmeisterError catch (e) {
setState(() {
_errorMessage = e.message;
_isLoadingTalkgroups = false;
_isLoadingDeviceDetails = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoadingTalkgroups = false;
_isLoadingDeviceDetails = false;
});
}
}
Future<void> _unlinkTalkgroup(StaticTalkgroup talkgroup) async {
if (talkgroup.talkgroup == null || talkgroup.slot == null) {
return;
}
try {
final authManager = context.read<AuthenticationManager>();
await authManager.unlinkTalkgroup(
talkgroupId: talkgroup.talkgroup!,
dmrId: widget.device.id,
timeslot: talkgroup.slot!,
);
await _loadData();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Talkgroup unlinked successfully')),
);
}
} on BrandmeisterError catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to unlink: ${e.message}')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
}
}
}
void _showLinkTalkgroupSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: LinkTalkgroupView(
device: widget.device,
onSuccess: () {
Navigator.pop(context);
_loadData();
},
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.device.callsign ?? 'Device Details'),
),
floatingActionButton: FloatingActionButton(
onPressed: _showLinkTalkgroupSheet,
child: const Icon(Icons.add),
),
body: RefreshIndicator(
onRefresh: _loadData,
child: _buildBody(),
),
);
}
Widget _buildBody() {
if ((_isLoadingTalkgroups || _isLoadingDeviceDetails) &&
_talkgroups.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Error',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadData,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
return ListView(
children: [
_buildDeviceInfoSection(),
const Divider(height: 1),
_buildTalkgroupsSection(),
],
);
}
Widget _buildDeviceInfoSection() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Device Information',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_InfoRow(
label: 'DMR ID',
value: widget.device.id.toString(),
),
if (widget.device.callsign != null)
_InfoRow(
label: 'Callsign',
value: widget.device.callsign!,
),
if (widget.device.name != null)
_InfoRow(
label: 'Name',
value: widget.device.name!,
),
_InfoRow(
label: 'Location',
value: widget.device.displayLocation,
),
if (widget.device.hardware != null)
_InfoRow(
label: 'Hardware',
value: widget.device.hardware!,
),
if (widget.device.firmware != null)
_InfoRow(
label: 'Firmware',
value: widget.device.firmware!,
),
],
),
);
}
Widget _buildTalkgroupsSection() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Static Talkgroups',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (_isLoadingTalkgroups)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const SizedBox(height: 16),
if (_talkgroups.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
Icons.speaker_group,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 8),
Text(
'No talkgroups linked',
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
)
else
..._talkgroups.map((tg) => _TalkgroupRow(
talkgroup: tg,
talkgroupName: _allTalkgroups[tg.talkgroup],
onDelete: () => _unlinkTalkgroup(tg),
)),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
);
}
}
class _TalkgroupRow extends StatelessWidget {
final StaticTalkgroup talkgroup;
final String? talkgroupName;
final VoidCallback onDelete;
const _TalkgroupRow({
required this.talkgroup,
this.talkgroupName,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
child: Text(
talkgroup.displaySlot,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
),
title: Text(talkgroupName ?? talkgroup.displayId),
subtitle: Text('ID: ${talkgroup.displayId}'),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
color: Colors.red,
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Unlink Talkgroup'),
content: Text(
'Are you sure you want to unlink talkgroup ${talkgroup.displayId}?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
onDelete();
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Unlink'),
),
],
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../models/device.dart';
import '../services/authentication_manager.dart';
import '../services/brandmeister_client.dart';
class LinkTalkgroupView extends StatefulWidget {
final Device device;
final VoidCallback onSuccess;
const LinkTalkgroupView({
super.key,
required this.device,
required this.onSuccess,
});
@override
State<LinkTalkgroupView> createState() => _LinkTalkgroupViewState();
}
class _LinkTalkgroupViewState extends State<LinkTalkgroupView> {
final TextEditingController _talkgroupController = TextEditingController();
int _selectedTimeslot = 1;
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_talkgroupController.dispose();
super.dispose();
}
Future<void> _linkTalkgroup() async {
if (_talkgroupController.text.isEmpty) {
setState(() {
_errorMessage = 'Please enter a talkgroup ID';
});
return;
}
final talkgroupId = int.tryParse(_talkgroupController.text);
if (talkgroupId == null) {
setState(() {
_errorMessage = 'Invalid talkgroup ID';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final authManager = context.read<AuthenticationManager>();
await authManager.linkTalkgroup(
talkgroupId: talkgroupId,
dmrId: widget.device.id,
timeslot: _selectedTimeslot,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Talkgroup linked successfully')),
);
widget.onSuccess();
}
} on BrandmeisterError catch (e) {
setState(() {
_errorMessage = e.message;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Link Talkgroup',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 24),
TextField(
controller: _talkgroupController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
labelText: 'Talkgroup ID',
hintText: 'Enter talkgroup ID',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.speaker_group),
errorText: _errorMessage,
),
enabled: !_isLoading,
autofocus: true,
),
const SizedBox(height: 24),
Text(
'Timeslot',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(
value: 1,
label: Text('TS1'),
icon: Icon(Icons.looks_one),
),
ButtonSegment(
value: 2,
label: Text('TS2'),
icon: Icon(Icons.looks_two),
),
],
selected: {_selectedTimeslot},
onSelectionChanged: _isLoading
? null
: (Set<int> newSelection) {
setState(() {
_selectedTimeslot = newSelection.first;
});
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _linkTalkgroup,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Link Talkgroup'),
),
],
),
);
}
}

225
lib/views/main_view.dart Normal file
View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/device.dart';
import '../models/user_info.dart';
import '../services/authentication_manager.dart';
import '../services/brandmeister_client.dart';
import 'device_detail_view.dart';
class MainView extends StatefulWidget {
const MainView({super.key});
@override
State<MainView> createState() => _MainViewState();
}
class _MainViewState extends State<MainView> {
List<Device> _devices = [];
bool _isLoadingDevices = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadDevices();
}
Future<void> _loadDevices() async {
setState(() {
_isLoadingDevices = true;
_errorMessage = null;
});
try {
final authManager = context.read<AuthenticationManager>();
final devices = await authManager.getDevices();
setState(() {
_devices = devices;
_isLoadingDevices = false;
});
} on BrandmeisterError catch (e) {
setState(() {
_errorMessage = e.message;
_isLoadingDevices = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoadingDevices = false;
});
}
}
Future<void> _logout() async {
final authManager = context.read<AuthenticationManager>();
await authManager.logout();
}
@override
Widget build(BuildContext context) {
final authManager = context.watch<AuthenticationManager>();
final userInfo = authManager.userInfo;
return Scaffold(
appBar: AppBar(
title: const Text('BM Manager'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: _logout,
tooltip: 'Logout',
),
],
),
body: RefreshIndicator(
onRefresh: _loadDevices,
child: _buildBody(userInfo),
),
);
}
Widget _buildBody(UserInfo? userInfo) {
if (_isLoadingDevices && _devices.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Error',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadDevices,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
if (_devices.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.devices_other,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No devices found',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'You don\'t have any devices registered',
style: TextStyle(color: Colors.grey[600]),
),
],
),
);
}
return ListView(
children: [
if (userInfo != null)
Container(
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.primaryContainer,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
userInfo.displayName,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
'ID: ${userInfo.id}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7),
),
),
],
),
),
..._devices.map((device) => _DeviceRow(
device: device,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DeviceDetailView(device: device),
),
);
},
)),
],
);
}
}
class _DeviceRow extends StatelessWidget {
final Device device;
final VoidCallback onTap;
const _DeviceRow({
required this.device,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.radio,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
title: Text(device.callsign ?? 'Unknown'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ID: ${device.id}'),
if (device.name != null) Text(device.name!),
Text(device.displayLocation),
],
),
isThreeLine: true,
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
}

138
lib/views/welcome_view.dart Normal file
View File

@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/authentication_manager.dart';
import '../services/brandmeister_client.dart';
class WelcomeView extends StatefulWidget {
const WelcomeView({super.key});
@override
State<WelcomeView> createState() => _WelcomeViewState();
}
class _WelcomeViewState extends State<WelcomeView> {
final TextEditingController _tokenController = TextEditingController();
final FocusNode _focusNode = FocusNode();
bool _isLoading = false;
String? _errorMessage;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_tokenController.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _verifyToken() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final authManager = context.read<AuthenticationManager>();
await authManager.verifyAndSaveToken(_tokenController.text);
} on BrandmeisterError catch (e) {
setState(() {
_errorMessage = e.message;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Authentication failed: ${e.toString()}';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.radio,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'BM Manager',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Manage your BrandMeister devices',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: _tokenController,
focusNode: _focusNode,
obscureText: true,
enabled: !_isLoading,
decoration: InputDecoration(
labelText: 'API Token',
hintText: 'Enter your BrandMeister API token',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key),
errorText: _errorMessage,
),
onSubmitted: (_) {
if (_tokenController.text.isNotEmpty && !_isLoading) {
_verifyToken();
}
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _tokenController.text.isEmpty || _isLoading
? null
: _verifyToken,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign In'),
),
const SizedBox(height: 24),
TextButton.icon(
onPressed: () {
// Open BrandMeister website
},
icon: const Icon(Icons.open_in_new),
label: const Text('Get API Token from BrandMeister'),
),
],
),
),
),
),
);
}
}