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