From 7416f7c29b0a5ef7a3ac07638bc942b946d7aa3c Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 20 Jan 2026 17:52:54 +0100 Subject: [PATCH] Move things from tabs, add me view --- lib/models/static_talkgroup.dart | 4 + lib/views/device_detail_view.dart | 121 ++++++++++++++++-- lib/views/main_view.dart | 22 ++-- lib/views/me_view.dart | 126 +++++++++++++++++++ lib/views/more_view.dart | 201 ++++++++++++++++++++++++++++++ 5 files changed, 453 insertions(+), 21 deletions(-) create mode 100644 lib/views/me_view.dart create mode 100644 lib/views/more_view.dart diff --git a/lib/models/static_talkgroup.dart b/lib/models/static_talkgroup.dart index d67f8a9..bdf4742 100644 --- a/lib/models/static_talkgroup.dart +++ b/lib/models/static_talkgroup.dart @@ -2,11 +2,13 @@ class StaticTalkgroup { final String? talkgroup; final String? repeaterId; final String? slot; + final int? timeout; StaticTalkgroup({ this.talkgroup, this.repeaterId, this.slot, + this.timeout, }); factory StaticTalkgroup.fromJson(Map json) { @@ -14,6 +16,7 @@ class StaticTalkgroup { talkgroup: json['talkgroup']?.toString(), repeaterId: json['repeaterId']?.toString(), slot: json['slot']?.toString(), + timeout: json['timeout'] as int?, ); } @@ -22,6 +25,7 @@ class StaticTalkgroup { 'talkgroup': talkgroup, 'repeaterId': repeaterId, 'slot': slot, + 'timeout': timeout, }; } diff --git a/lib/views/device_detail_view.dart b/lib/views/device_detail_view.dart index 275b937..c43f14d 100644 --- a/lib/views/device_detail_view.dart +++ b/lib/views/device_detail_view.dart @@ -32,6 +32,8 @@ class _DeviceDetailViewState extends State { BrandmeisterWebSocketClient? _wsClient; StreamSubscription>? _wsSubscription; int? _autoStaticTalkgroup; + Map _dynamicTimeouts = {}; + Timer? _countdownTimer; LastHeardWebSocketClient? _lhWsClient; StreamSubscription>? _lhWsSubscription; @@ -52,6 +54,7 @@ class _DeviceDetailViewState extends State { _wsClient?.dispose(); _lhWsSubscription?.cancel(); _lhWsClient?.dispose(); + _countdownTimer?.cancel(); super.dispose(); } @@ -101,6 +104,41 @@ class _DeviceDetailViewState extends State { } } + void _startCountdownTimer() { + _countdownTimer?.cancel(); + + if (_dynamicTimeouts.isEmpty) return; + + // Update countdown every second + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final keysToRemove = []; + _dynamicTimeouts.forEach((key, value) { + if (value > 0) { + _dynamicTimeouts[key] = value - 1; + } else { + keysToRemove.add(key); + } + }); + + // If any entries expired, reload data to get updated list from server + if (keysToRemove.isNotEmpty) { + for (final key in keysToRemove) { + _dynamicTimeouts.remove(key); + } + // Reload data to get the updated device profile + _loadData(); + } else { + // Just update UI for countdown + setState(() {}); + } + + // Stop timer if no more timeouts + if (_dynamicTimeouts.isEmpty) { + timer.cancel(); + } + }); + } + Future _connectLastHeardWebSocket() async { try { _lhWsClient = LastHeardWebSocketClient(); @@ -211,6 +249,27 @@ class _DeviceDetailViewState extends State { _talkgroups = results[0] as List; _allTalkgroups = results[1] as Map; _deviceProfile = results[2] as DeviceProfile; + + // Initialize dynamic timeouts from device profile + _dynamicTimeouts.clear(); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Current Unix timestamp in seconds + + for (final tg in _deviceProfile!.dynamicSubscriptions) { + if (tg.talkgroup != null && tg.timeout != null && tg.timeout! > 0) { + final key = '${tg.talkgroup}_${tg.slot}'; + // Convert Unix timestamp to remaining seconds + final remainingSeconds = tg.timeout! - now; + if (remainingSeconds > 0) { + _dynamicTimeouts[key] = remainingSeconds; + } + } + } + + // Start countdown timer if we have any dynamic timeouts + if (_dynamicTimeouts.isNotEmpty) { + _startCountdownTimer(); + } + _isLoadingTalkgroups = false; _isLoadingDeviceDetails = false; }); @@ -581,11 +640,12 @@ class _DeviceDetailViewState extends State { // Dynamic Subscriptions (Autostatic) if (_deviceProfile?.dynamicSubscriptions.isNotEmpty ?? false) ...[ _buildTalkgroupCategory( - 'Dynamic / Autostatic', + 'Dynamic', _deviceProfile!.dynamicSubscriptions, Colors.green, Icons.autorenew, - canDelete: false, + canDelete: true, + showTimeout: true, ), const SizedBox(height: 16), ], @@ -701,12 +761,27 @@ class _DeviceDetailViewState extends State { ); } + String _formatTimeout(int seconds) { + final duration = Duration(seconds: seconds); + if (duration.inHours > 0) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + return '${hours}h ${minutes}m'; + } else if (duration.inMinutes > 0) { + final minutes = duration.inMinutes; + final secs = duration.inSeconds.remainder(60); + return '${minutes}m ${secs}s'; + } else { + return '${duration.inSeconds}s'; + } + } + Widget _buildTalkgroupCategory( String title, List talkgroups, Color color, IconData icon, - {bool canDelete = false} + {bool canDelete = false, bool showTimeout = false} ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -741,12 +816,18 @@ class _DeviceDetailViewState extends State { ], ), const SizedBox(height: 8), - ...talkgroups.map((tg) => _TalkgroupRow( - talkgroup: tg, - talkgroupName: _allTalkgroups[tg.talkgroup], - onDelete: canDelete ? () => _unlinkTalkgroup(tg) : null, - categoryColor: color, - )), + ...talkgroups.map((tg) { + final key = '${tg.talkgroup}_${tg.slot}'; + final timeout = showTimeout ? _dynamicTimeouts[key] : null; + return _TalkgroupRow( + talkgroup: tg, + talkgroupName: _allTalkgroups[tg.talkgroup], + onDelete: canDelete ? () => _unlinkTalkgroup(tg) : null, + categoryColor: color, + timeout: timeout, + formatTimeout: _formatTimeout, + ); + }), ], ); } @@ -795,17 +876,22 @@ class _TalkgroupRow extends StatelessWidget { final String? talkgroupName; final VoidCallback? onDelete; final Color? categoryColor; + final int? timeout; + final String Function(int)? formatTimeout; const _TalkgroupRow({ required this.talkgroup, this.talkgroupName, this.onDelete, this.categoryColor, + this.timeout, + this.formatTimeout, }); @override Widget build(BuildContext context) { final color = categoryColor ?? Theme.of(context).colorScheme.primary; + final hasTimeout = timeout != null && timeout! > 0 && formatTimeout != null; return Card( margin: const EdgeInsets.only(bottom: 8), @@ -822,7 +908,21 @@ class _TalkgroupRow extends StatelessWidget { ), ), title: Text(talkgroupName ?? talkgroup.displayId), - subtitle: Text('ID: ${talkgroup.displayId}'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('ID: ${talkgroup.displayId}'), + if (hasTimeout) + Text( + 'Expires in ${formatTimeout!(timeout!)}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ], + ), trailing: onDelete != null ? IconButton( icon: const Icon(Icons.delete_outline), @@ -854,6 +954,7 @@ class _TalkgroupRow extends StatelessWidget { }, ) : null, + isThreeLine: hasTimeout, ), ); } diff --git a/lib/views/main_view.dart b/lib/views/main_view.dart index 6bdb759..97b1591 100644 --- a/lib/views/main_view.dart +++ b/lib/views/main_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'info_view.dart'; -import 'last_heard_view.dart'; +import 'me_view.dart'; import 'devices_view.dart'; -import 'hose_view.dart'; +import 'last_heard_view.dart'; +import 'more_view.dart'; class MainView extends StatefulWidget { const MainView({super.key}); @@ -15,10 +15,10 @@ class _MainViewState extends State { int _selectedIndex = 0; final List _views = const [ + MeView(), DevicesView(), - HoseView(), LastHeardView(), - InfoView(), + MoreView(), ]; @override @@ -31,20 +31,20 @@ class _MainViewState extends State { onTap: (index) => setState(() => _selectedIndex = index), items: const [ BottomNavigationBarItem( - icon: Icon(Icons.devices), - label: 'Devices', + icon: Icon(Icons.person), + label: 'Me', ), BottomNavigationBarItem( - icon: Icon(Icons.water_drop), - label: 'Hose', + icon: Icon(Icons.devices), + label: 'Devices', ), BottomNavigationBarItem( icon: Icon(Icons.history), label: 'Last Heard', ), BottomNavigationBarItem( - icon: Icon(Icons.info), - label: 'Info', + icon: Icon(Icons.more_horiz), + label: 'More', ), ], ), diff --git a/lib/views/me_view.dart b/lib/views/me_view.dart new file mode 100644 index 0000000..06fae7f --- /dev/null +++ b/lib/views/me_view.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/authentication_manager.dart'; + +class MeView extends StatefulWidget { + const MeView({super.key}); + + @override + State createState() => _MeViewState(); +} + +class _MeViewState extends State { + + @override + Widget build(BuildContext context) { + final authManager = context.watch(); + final userInfo = authManager.userInfo; + + return Scaffold( + appBar: AppBar( + title: const Text('Me'), + ), + body: ListView( + children: [ + _buildUserInfoSection(context, userInfo), + ], + ), + ); + } + + Widget _buildUserInfoSection(BuildContext context, userInfo) { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 32, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + Icons.person, + size: 32, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + userInfo?.name ?? 'N/A', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + userInfo?.username ?? 'N/A', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + _InfoRow( + icon: Icons.numbers, + label: 'User ID', + value: userInfo?.id.toString() ?? 'N/A', + ), + ], + ), + ); + } + +} + +class _InfoRow extends StatelessWidget { + final IconData icon; + final String label; + final String value; + + const _InfoRow({ + required this.icon, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/more_view.dart b/lib/views/more_view.dart new file mode 100644 index 0000000..f541f0e --- /dev/null +++ b/lib/views/more_view.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/authentication_manager.dart'; +import 'hose_view.dart'; + +class MoreView extends StatelessWidget { + const MoreView({super.key}); + + @override + Widget build(BuildContext context) { + final authManager = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('More'), + ), + body: ListView( + children: [ + _buildFeaturesSection(context), + const Divider(height: 1), + _buildAppInfoSection(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), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Application', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + const _InfoRow( + icon: Icons.info, + label: 'App Name', + value: 'BM Manager', + ), + const _InfoRow( + icon: Icons.analytics, + label: 'Version', + value: '1.0.0', + ), + const _InfoRow( + icon: Icons.copyright, + label: 'Copyright', + value: '2026', + ), + ], + ), + ); + } + + Widget _buildLogoutSection( + BuildContext context, AuthenticationManager authManager) { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Account', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: + TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Logout'), + ), + ], + ), + ); + + if (confirm == true) { + await authManager.logout(); + } + }, + icon: const Icon(Icons.logout), + label: const Text('Logout'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final IconData icon; + final String label; + final String value; + + const _InfoRow({ + required this.icon, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ); + } +}