Add faster location update channel

This commit is contained in:
Janez T
2026-03-07 20:38:49 +01:00
parent 3433eae3a6
commit fe2e08c4e4

View File

@@ -27,6 +27,8 @@ import '../utils/battery_display_helper.dart';
enum _HomeTab { messages, contacts, sensors, map } enum _HomeTab { messages, contacts, sensors, map }
enum _AdvertMode { flood, direct }
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged; final Function(AppThemeMode) onThemeChanged;
final Function(Locale?) onLocaleChanged; final Function(Locale?) onLocaleChanged;
@@ -245,7 +247,187 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
); );
} }
Future<void> _advertiseDevice(BuildContext context) async { Future<void> _triggerAdvertFeedback() async {
final platform = Theme.of(context).platform;
try {
if (platform == TargetPlatform.iOS) {
await HapticFeedback.lightImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.lightImpact();
} else {
if (await Vibration.hasVibrator()) {
await Vibration.vibrate(duration: 50);
} else {
await HapticFeedback.mediumImpact();
}
}
} catch (e) {
debugPrint('Haptic feedback error: $e');
await HapticFeedback.vibrate();
}
}
Future<_AdvertMode?> _showAdvertModeSheet(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return showModalBottomSheet<_AdvertMode>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Advert mode',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Choose how far this announcement should travel.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.hub_rounded,
color: theme.colorScheme.onPrimaryContainer,
),
),
title: Text(l10n.flood),
subtitle: const Text('Relay through repeaters across the mesh'),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
onTap: () => Navigator.of(context).pop(_AdvertMode.flood),
),
const SizedBox(height: 8),
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.near_me_rounded,
color: theme.colorScheme.onSecondaryContainer,
),
),
title: Text(l10n.direct),
subtitle: const Text('Nearby only, without repeater flooding'),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
onTap: () => Navigator.of(context).pop(_AdvertMode.direct),
),
],
),
),
),
);
}
Widget _buildActivityBadge({
required String label,
required int count,
required bool isActive,
required Color activeColor,
}) {
final color = isActive ? activeColor : Colors.grey;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(width: 6),
Text(
'$label:$count',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
Widget _buildCompactActivityIndicator({
required bool rxActive,
required bool txActive,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: rxActive ? Colors.green : Colors.grey,
),
),
const SizedBox(width: 6),
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: txActive ? Colors.blue : Colors.grey,
),
),
],
),
);
}
Future<void> _advertiseDevice(
BuildContext context, {
bool floodMode = true,
}) async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) { if (!connectionProvider.deviceInfo.isConnected) {
@@ -325,8 +507,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Small delay to ensure the lat/lon is set // Small delay to ensure the lat/lon is set
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
// Send flood advertisement await connectionProvider.sendSelfAdvert(floodMode: floodMode);
await connectionProvider.sendSelfAdvert(floodMode: true);
if (context.mounted) {
ToastLogger.success(
context,
floodMode ? 'Flood advert sent' : 'Direct advert sent',
);
}
} catch (e) { } catch (e) {
debugPrint('❌ Failed to advertise device: $e'); debugPrint('❌ Failed to advertise device: $e');
if (context.mounted) { if (context.mounted) {
@@ -367,6 +555,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
appBar: shouldHideUI appBar: shouldHideUI
? null ? null
: AppBar( : AppBar(
toolbarHeight: 64,
titleSpacing: 8,
title: _buildCompactStatusBar(), title: _buildCompactStatusBar(),
actions: [ actions: [
Consumer<ConnectionProvider>( Consumer<ConnectionProvider>(
@@ -565,7 +755,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final deviceInfo = provider.deviceInfo; final deviceInfo = provider.deviceInfo;
final isConnected = deviceInfo.isConnected; final isConnected = deviceInfo.isConnected;
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp; final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
final isBleConnected = isConnected && !isTcpConnected;
if (!isConnected) { if (!isConnected) {
// Disconnected state: show connect button // Disconnected state: show connect button
@@ -627,238 +816,242 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
); );
} }
// Connected state: LEFT | CENTER | RIGHT layout final theme = Theme.of(context);
return Row( final subtitleColor = theme.colorScheme.onSurfaceVariant;
children: [ final signalColor = isTcpConnected
// LEFT: Name + BT/Battery + Cog ? Colors.green
Expanded( : (deviceInfo.signalRssi != null
child: Row( ? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!)
mainAxisSize: MainAxisSize.min, : Colors.grey);
children: [
Flexible( return LayoutBuilder(
child: Column( builder: (context, constraints) {
crossAxisAlignment: CrossAxisAlignment.start, final isTight = constraints.maxWidth < 360;
return Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(22),
),
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Flexible(
deviceInfo.selfName ?? fit: FlexFit.loose,
AppLocalizations.of(context)!.appTitle, child: Column(
style: const TextStyle( mainAxisSize: MainAxisSize.min,
fontSize: 18, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.bold, children: [
Text(
deviceInfo.selfName ??
AppLocalizations.of(context)!.appTitle,
style: (isTight
? theme.textTheme.titleSmall
: theme.textTheme.titleMedium)
?.copyWith(fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isTcpConnected
? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded,
size: 13,
color: signalColor,
),
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
SizedBox(
width: 28,
child: Text(
'${deviceInfo.signalRssi}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: signalColor,
),
maxLines: 1,
),
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
const SizedBox(width: 4),
SizedBox(
width: 30,
child: Text(
'${deviceInfo.batteryPercent!.round()}%',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
),
],
],
),
],
), ),
overflow: TextOverflow.ellipsis,
), ),
Row( const SizedBox(width: 4),
mainAxisSize: MainAxisSize.min, IconButton(
children: [ onPressed: () {
Icon( Navigator.push(
isTcpConnected context,
? Icons.wifi MaterialPageRoute(
: Icons.bluetooth_connected, builder: (context) => const DeviceConfigScreen(),
color: isTcpConnected ),
? Colors.green );
: (deviceInfo.signalRssi != null },
? BatteryDisplayHelper.getSignalColor( onLongPress: () {
deviceInfo.signalRssi!, Navigator.push(
) context,
: Colors.grey), MaterialPageRoute(
size: 13, builder: (context) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
tooltip: AppLocalizations.of(context)!.settings,
icon: const Icon(Icons.tune_rounded),
color: subtitleColor,
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface,
foregroundColor: subtitleColor,
minimumSize: Size.square(isTight ? 38 : 40),
padding: EdgeInsets.zero,
),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () async {
await _triggerAdvertFeedback();
if (!mounted || !context.mounted) return;
await _advertiseDevice(context);
},
onLongPress: () async {
await _triggerAdvertFeedback();
if (!mounted || !context.mounted) return;
final mode = await _showAdvertModeSheet(context);
if (!mounted || !context.mounted || mode == null) {
return;
}
await _advertiseDevice(
context,
floodMode: mode == _AdvertMode.flood,
);
},
child: Container(
width: isTight ? 38 : 40,
height: isTight ? 38 : 40,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.primary.withValues(
alpha: 0.78,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
), ),
if (isBleConnected && child: Icon(
deviceInfo.signalRssi != null) ...[ Icons.campaign_rounded,
const SizedBox(width: 3), color: Colors.white,
Text( size: isTight ? 18 : 20,
'${deviceInfo.signalRssi}', ),
style: TextStyle( ),
fontSize: 11,
color: BatteryDisplayHelper.getSignalColor(
deviceInfo.signalRssi!,
),
),
),
],
if (isTcpConnected) ...[
const SizedBox(width: 3),
Text(
'WiFi',
style: const TextStyle(
fontSize: 11,
color: Colors.green,
),
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
size: 13,
),
const SizedBox(width: 3),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 11,
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
],
],
), ),
], ],
), ),
), ),
const SizedBox(width: 8), ),
SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators)
GestureDetector( GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DeviceConfigScreen(),
),
);
},
onLongPress: () { onLongPress: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PacketLogScreen( builder: (context) =>
bleService: provider.bleService, PacketLogScreen(bleService: provider.bleService),
),
), ),
); );
}, },
child: Container( child: isTight
width: 32, ? _buildCompactActivityIndicator(
height: 32, rxActive: provider.rxActivity,
alignment: Alignment.center, txActive: provider.txActivity,
child: const Icon(Icons.settings, size: 18), )
), : Container(
), padding: const EdgeInsets.symmetric(
], horizontal: 10,
), vertical: 8,
), ),
decoration: BoxDecoration(
// CENTER: Broadcast button color: theme.colorScheme.surfaceContainerHigh
const SizedBox(width: 8), .withValues(alpha: 0.85),
FilledButton( borderRadius: BorderRadius.circular(20),
onPressed: () async { ),
// Capture platform before async operations child: Column(
final platform = Theme.of(context).platform; mainAxisSize: MainAxisSize.min,
// iOS: Use haptic feedback (always works) crossAxisAlignment: CrossAxisAlignment.end,
// Android: Try vibration package for better control children: [
try { _buildActivityBadge(
if (platform == TargetPlatform.iOS) { label: 'RX',
// iOS: Try multiple haptic types for reliability count: provider.rxPacketCount,
await HapticFeedback.lightImpact(); isActive: provider.rxActivity,
await Future.delayed(const Duration(milliseconds: 50)); activeColor: Colors.green,
await HapticFeedback.lightImpact(); ),
} else { const SizedBox(height: 6),
// Android vibration _buildActivityBadge(
if (await Vibration.hasVibrator()) { label: 'TX',
await Vibration.vibrate(duration: 50); count: provider.txPacketCount,
} else { isActive: provider.txActivity,
await HapticFeedback.mediumImpact(); activeColor: Colors.blue,
} ),
} ],
} catch (e) { ),
// Fallback if anything fails
debugPrint('Haptic feedback error: $e');
await HapticFeedback.vibrate();
}
if (!mounted) return;
if (!context.mounted) return;
_advertiseDevice(context);
},
style: FilledButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(10),
minimumSize: const Size(40, 40),
shape: const CircleBorder(),
),
child: const Icon(Icons.campaign, size: 20),
),
const SizedBox(width: 8),
// RIGHT: RX/TX indicators
if (_showRxTxIndicators)
GestureDetector(
onLongPress: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PacketLogScreen(bleService: provider.bleService),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.rxActivity
? Colors.green
: Colors.grey.withValues(alpha: 0.3),
), ),
), )
const SizedBox(width: 3), else
Text( SizedBox(width: isTight ? 24 : 74),
'RX:${provider.rxPacketCount}', ],
style: const TextStyle( );
fontSize: 10, },
color: Colors.grey,
),
),
],
),
const SizedBox(height: 3),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.txActivity
? Colors.blue
: Colors.grey.withValues(alpha: 0.3),
),
),
const SizedBox(width: 3),
Text(
'TX:${provider.txPacketCount}',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
),
],
),
)
else
const SizedBox(
width: 52,
), // Placeholder to maintain layout balance
],
); );
}, },
); );