Move things from tabs, add me view

This commit is contained in:
2026-01-20 17:52:54 +01:00
parent 4fc8570d66
commit 7416f7c29b
5 changed files with 453 additions and 21 deletions

View File

@@ -2,11 +2,13 @@ class StaticTalkgroup {
final String? talkgroup; final String? talkgroup;
final String? repeaterId; final String? repeaterId;
final String? slot; final String? slot;
final int? timeout;
StaticTalkgroup({ StaticTalkgroup({
this.talkgroup, this.talkgroup,
this.repeaterId, this.repeaterId,
this.slot, this.slot,
this.timeout,
}); });
factory StaticTalkgroup.fromJson(Map<String, dynamic> json) { factory StaticTalkgroup.fromJson(Map<String, dynamic> json) {
@@ -14,6 +16,7 @@ class StaticTalkgroup {
talkgroup: json['talkgroup']?.toString(), talkgroup: json['talkgroup']?.toString(),
repeaterId: json['repeaterId']?.toString(), repeaterId: json['repeaterId']?.toString(),
slot: json['slot']?.toString(), slot: json['slot']?.toString(),
timeout: json['timeout'] as int?,
); );
} }
@@ -22,6 +25,7 @@ class StaticTalkgroup {
'talkgroup': talkgroup, 'talkgroup': talkgroup,
'repeaterId': repeaterId, 'repeaterId': repeaterId,
'slot': slot, 'slot': slot,
'timeout': timeout,
}; };
} }

View File

@@ -32,6 +32,8 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
BrandmeisterWebSocketClient? _wsClient; BrandmeisterWebSocketClient? _wsClient;
StreamSubscription<Map<String, dynamic>>? _wsSubscription; StreamSubscription<Map<String, dynamic>>? _wsSubscription;
int? _autoStaticTalkgroup; int? _autoStaticTalkgroup;
Map<String, int> _dynamicTimeouts = {};
Timer? _countdownTimer;
LastHeardWebSocketClient? _lhWsClient; LastHeardWebSocketClient? _lhWsClient;
StreamSubscription<Map<String, dynamic>>? _lhWsSubscription; StreamSubscription<Map<String, dynamic>>? _lhWsSubscription;
@@ -52,6 +54,7 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
_wsClient?.dispose(); _wsClient?.dispose();
_lhWsSubscription?.cancel(); _lhWsSubscription?.cancel();
_lhWsClient?.dispose(); _lhWsClient?.dispose();
_countdownTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -101,6 +104,41 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
} }
} }
void _startCountdownTimer() {
_countdownTimer?.cancel();
if (_dynamicTimeouts.isEmpty) return;
// Update countdown every second
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final keysToRemove = <String>[];
_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<void> _connectLastHeardWebSocket() async { Future<void> _connectLastHeardWebSocket() async {
try { try {
_lhWsClient = LastHeardWebSocketClient(); _lhWsClient = LastHeardWebSocketClient();
@@ -211,6 +249,27 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
_talkgroups = results[0] as List<StaticTalkgroup>; _talkgroups = results[0] as List<StaticTalkgroup>;
_allTalkgroups = results[1] as Map<String, String>; _allTalkgroups = results[1] as Map<String, String>;
_deviceProfile = results[2] as DeviceProfile; _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; _isLoadingTalkgroups = false;
_isLoadingDeviceDetails = false; _isLoadingDeviceDetails = false;
}); });
@@ -581,11 +640,12 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
// Dynamic Subscriptions (Autostatic) // Dynamic Subscriptions (Autostatic)
if (_deviceProfile?.dynamicSubscriptions.isNotEmpty ?? false) ...[ if (_deviceProfile?.dynamicSubscriptions.isNotEmpty ?? false) ...[
_buildTalkgroupCategory( _buildTalkgroupCategory(
'Dynamic / Autostatic', 'Dynamic',
_deviceProfile!.dynamicSubscriptions, _deviceProfile!.dynamicSubscriptions,
Colors.green, Colors.green,
Icons.autorenew, Icons.autorenew,
canDelete: false, canDelete: true,
showTimeout: true,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@@ -701,12 +761,27 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
); );
} }
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( Widget _buildTalkgroupCategory(
String title, String title,
List<StaticTalkgroup> talkgroups, List<StaticTalkgroup> talkgroups,
Color color, Color color,
IconData icon, IconData icon,
{bool canDelete = false} {bool canDelete = false, bool showTimeout = false}
) { ) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -741,12 +816,18 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
...talkgroups.map((tg) => _TalkgroupRow( ...talkgroups.map((tg) {
talkgroup: tg, final key = '${tg.talkgroup}_${tg.slot}';
talkgroupName: _allTalkgroups[tg.talkgroup], final timeout = showTimeout ? _dynamicTimeouts[key] : null;
onDelete: canDelete ? () => _unlinkTalkgroup(tg) : null, return _TalkgroupRow(
categoryColor: color, 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 String? talkgroupName;
final VoidCallback? onDelete; final VoidCallback? onDelete;
final Color? categoryColor; final Color? categoryColor;
final int? timeout;
final String Function(int)? formatTimeout;
const _TalkgroupRow({ const _TalkgroupRow({
required this.talkgroup, required this.talkgroup,
this.talkgroupName, this.talkgroupName,
this.onDelete, this.onDelete,
this.categoryColor, this.categoryColor,
this.timeout,
this.formatTimeout,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = categoryColor ?? Theme.of(context).colorScheme.primary; final color = categoryColor ?? Theme.of(context).colorScheme.primary;
final hasTimeout = timeout != null && timeout! > 0 && formatTimeout != null;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -822,7 +908,21 @@ class _TalkgroupRow extends StatelessWidget {
), ),
), ),
title: Text(talkgroupName ?? talkgroup.displayId), 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 trailing: onDelete != null
? IconButton( ? IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
@@ -854,6 +954,7 @@ class _TalkgroupRow extends StatelessWidget {
}, },
) )
: null, : null,
isThreeLine: hasTimeout,
), ),
); );
} }

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'info_view.dart'; import 'me_view.dart';
import 'last_heard_view.dart';
import 'devices_view.dart'; import 'devices_view.dart';
import 'hose_view.dart'; import 'last_heard_view.dart';
import 'more_view.dart';
class MainView extends StatefulWidget { class MainView extends StatefulWidget {
const MainView({super.key}); const MainView({super.key});
@@ -15,10 +15,10 @@ class _MainViewState extends State<MainView> {
int _selectedIndex = 0; int _selectedIndex = 0;
final List<Widget> _views = const [ final List<Widget> _views = const [
MeView(),
DevicesView(), DevicesView(),
HoseView(),
LastHeardView(), LastHeardView(),
InfoView(), MoreView(),
]; ];
@override @override
@@ -31,20 +31,20 @@ class _MainViewState extends State<MainView> {
onTap: (index) => setState(() => _selectedIndex = index), onTap: (index) => setState(() => _selectedIndex = index),
items: const [ items: const [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.devices), icon: Icon(Icons.person),
label: 'Devices', label: 'Me',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.water_drop), icon: Icon(Icons.devices),
label: 'Hose', label: 'Devices',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.history), icon: Icon(Icons.history),
label: 'Last Heard', label: 'Last Heard',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.info), icon: Icon(Icons.more_horiz),
label: 'Info', label: 'More',
), ),
], ],
), ),

126
lib/views/me_view.dart Normal file
View File

@@ -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<MeView> createState() => _MeViewState();
}
class _MeViewState extends State<MeView> {
@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: [
_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,
),
),
],
),
),
],
),
);
}
}

201
lib/views/more_view.dart Normal file
View File

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