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,64 +816,74 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
); );
} }
// Connected state: LEFT | CENTER | RIGHT layout final theme = Theme.of(context);
final subtitleColor = theme.colorScheme.onSurfaceVariant;
final signalColor = isTcpConnected
? Colors.green
: (deviceInfo.signalRssi != null
? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!)
: Colors.grey);
return LayoutBuilder(
builder: (context, constraints) {
final isTight = constraints.maxWidth < 360;
return Row( return Row(
children: [ children: [
// LEFT: Name + BT/Battery + Cog Flexible(
Expanded( 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( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Flexible( Flexible(
fit: FlexFit.loose,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
deviceInfo.selfName ?? deviceInfo.selfName ??
AppLocalizations.of(context)!.appTitle, AppLocalizations.of(context)!.appTitle,
style: const TextStyle( style: (isTight
fontSize: 18, ? theme.textTheme.titleSmall
fontWeight: FontWeight.bold, : theme.textTheme.titleMedium)
), ?.copyWith(fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
isTcpConnected isTcpConnected
? Icons.wifi ? Icons.wifi_rounded
: Icons.bluetooth_connected, : Icons.bluetooth_connected_rounded,
color: isTcpConnected
? Colors.green
: (deviceInfo.signalRssi != null
? BatteryDisplayHelper.getSignalColor(
deviceInfo.signalRssi!,
)
: Colors.grey),
size: 13, size: 13,
color: signalColor,
), ),
if (isBleConnected && if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[ deviceInfo.signalRssi != null) ...[
const SizedBox(width: 3), const SizedBox(width: 4),
Text( SizedBox(
width: 28,
child: Text(
'${deviceInfo.signalRssi}', '${deviceInfo.signalRssi}',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 10,
color: BatteryDisplayHelper.getSignalColor( fontWeight: FontWeight.w600,
deviceInfo.signalRssi!, color: signalColor,
), ),
), maxLines: 1,
),
],
if (isTcpConnected) ...[
const SizedBox(width: 3),
Text(
'WiFi',
style: const TextStyle(
fontSize: 11,
color: Colors.green,
), ),
), ),
], ],
@@ -694,30 +893,37 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
BatteryDisplayHelper.getBatteryIcon( BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!, deviceInfo.batteryPercent!,
), ),
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
size: 13, size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
), ),
const SizedBox(width: 3), ),
Text( const SizedBox(width: 4),
SizedBox(
width: 30,
child: Text(
'${deviceInfo.batteryPercent!.round()}%', '${deviceInfo.batteryPercent!.round()}%',
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 10,
color: BatteryDisplayHelper.getBatteryColor( fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!, deviceInfo.batteryPercent!,
), ),
), ),
), ),
),
], ],
], ],
), ),
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 4),
GestureDetector( IconButton(
onTap: () { onPressed: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -735,60 +941,65 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
), ),
); );
}, },
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( child: Container(
width: 32, width: isTight ? 38 : 40,
height: 32, height: isTight ? 38 : 40,
alignment: Alignment.center, decoration: BoxDecoration(
child: const Icon(Icons.settings, size: 18), gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.primary.withValues(
alpha: 0.78,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.campaign_rounded,
color: Colors.white,
size: isTight ? 18 : 20,
),
), ),
), ),
], ],
), ),
), ),
// CENTER: Broadcast button
const SizedBox(width: 8),
FilledButton(
onPressed: () async {
// Capture platform before async operations
final platform = Theme.of(context).platform;
// iOS: Use haptic feedback (always works)
// Android: Try vibration package for better control
try {
if (platform == TargetPlatform.iOS) {
// iOS: Try multiple haptic types for reliability
await HapticFeedback.lightImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.lightImpact();
} else {
// Android vibration
if (await Vibration.hasVibrator()) {
await Vibration.vibrate(duration: 50);
} else {
await HapticFeedback.mediumImpact();
}
}
} 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), SizedBox(width: isTight ? 8 : 12),
),
const SizedBox(width: 8),
// RIGHT: RX/TX indicators
if (_showRxTxIndicators) if (_showRxTxIndicators)
GestureDetector( GestureDetector(
onLongPress: () { onLongPress: () {
@@ -800,68 +1011,50 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
), ),
); );
}, },
child: isTight
? _buildCompactActivityIndicator(
rxActive: provider.rxActivity,
txActive: provider.txActivity,
)
: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Row( _buildActivityBadge(
mainAxisSize: MainAxisSize.min, label: 'RX',
children: [ count: provider.rxPacketCount,
Container( isActive: provider.rxActivity,
width: 7, activeColor: Colors.green,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.rxActivity
? Colors.green
: Colors.grey.withValues(alpha: 0.3),
),
),
const SizedBox(width: 3),
Text(
'RX:${provider.rxPacketCount}',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
), ),
const SizedBox(height: 6),
_buildActivityBadge(
label: 'TX',
count: provider.txPacketCount,
isActive: provider.txActivity,
activeColor: Colors.blue,
), ),
], ],
), ),
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 else
const SizedBox( SizedBox(width: isTight ? 24 : 74),
width: 52,
), // Placeholder to maintain layout balance
], ],
); );
}, },
); );
},
);
} }
/// Build tab icon with badge showing count /// Build tab icon with badge showing count