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

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