feat: UX polish, localization, advert relocation, and map filter fixes

- i18n: translate connection screen + chat tab keys into 13 locales;
  add "Send my contact" (advert) strings in all locales
- feat: move self-advert from home header into composer "+" action menu
  (new lib/utils/advert_helper.dart, always shows Flood/Direct sheet)
- fix: hide-repeaters map toggle was bypassed in simple mode
- fix: simple mode map now shows only favourite chat contacts
- fix: wrap ListTiles in Material (contact_tile secondary actions,
  inferred contact group card) to satisfy Flutter ink assertions
- device-authoritative contact/channel sync and UX review fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Janez T
2026-06-12 10:01:10 +02:00
parent 3e1113035d
commit ba27843166
62 changed files with 12546 additions and 2521 deletions

View File

@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import 'package:vibration/vibration.dart';
import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import 'toast_logger.dart';
enum AdvertMode { flood, direct }
Future<void> triggerAdvertFeedback(BuildContext context) 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(
l10n.advertMode,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
l10n.advertModeSubtitle,
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: Text(l10n.relayThroughRepeatersAcrossTheMesh),
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: Text(l10n.nearbyOnlyWithoutRepeaterFlooding),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
onTap: () => Navigator.of(context).pop(AdvertMode.direct),
),
],
),
),
),
);
}
Future<void> advertiseDevice(
BuildContext context, {
bool floodMode = true,
}) async {
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.deviceNotConnected,
);
}
return;
}
try {
// Check if location services are enabled
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.locationServicesDisabled,
);
}
return;
}
// Check location permissions
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.locationPermissionDenied,
);
}
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.locationPermissionPermanentlyDenied,
);
}
return;
}
// Get current GPS position
Position? position;
try {
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
).timeout(const Duration(seconds: 5));
} catch (e) {
debugPrint('❌ Failed to get GPS position: $e');
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToGetGpsLocation,
);
}
return;
}
// Update lat/lon on device
await connectionProvider.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
// Small delay to ensure the lat/lon is set
await Future.delayed(const Duration(milliseconds: 100));
await connectionProvider.sendSelfAdvert(floodMode: floodMode);
if (context.mounted) {
final l10n = AppLocalizations.of(context)!;
ToastLogger.success(
context,
floodMode ? l10n.floodAdvertSent : l10n.directAdvertSent,
);
}
} catch (e) {
debugPrint('❌ Failed to advertise device: $e');
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToAdvertise(e.toString()),
);
}
}
}

View File

@@ -2,7 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/messages_provider.dart';
/// Toast logger utility - replaces SnackBar with system messages in the channels tab
/// Global messenger key wired to [MaterialApp.scaffoldMessengerKey] in main.dart
/// so toasts can be shown even when the calling context has no Scaffold.
final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey =
GlobalKey<ScaffoldMessengerState>();
/// Toast logger utility - shows a floating SnackBar and records a system
/// message in the channels tab history
class ToastLogger {
/// Log an info message
static void info(BuildContext context, String message) {
@@ -24,14 +30,55 @@ class ToastLogger {
_log(context, message, 'error');
}
/// Internal method to log a system message
/// Internal method to show a snackbar and log a system message
static void _log(BuildContext context, String message, String level) {
_showSnackBar(context, message, level);
// Keep recording as a system message (feeds history/stats)
try {
final messagesProvider = context.read<MessagesProvider>();
messagesProvider.logSystemMessage(text: message, level: level);
} catch (e) {
// Fallback to print if provider is not available
// Provider not available - snackbar above is still shown
debugPrint('[$level] $message');
}
}
static void _showSnackBar(BuildContext context, String message, String level) {
final messenger =
rootScaffoldMessengerKey.currentState ??
ScaffoldMessenger.maybeOf(context);
if (messenger == null) {
debugPrint('[$level] $message');
return;
}
Color? backgroundColor;
switch (level) {
case 'error':
backgroundColor = context.mounted
? Theme.of(context).colorScheme.error
: Colors.red;
break;
case 'warning':
backgroundColor = Colors.orange;
break;
case 'success':
backgroundColor = Colors.green;
break;
default:
backgroundColor = null; // default surface color
}
// Don't queue rapid toasts behind each other
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
backgroundColor: backgroundColor,
duration: Duration(seconds: level == 'error' ? 4 : 3),
),
);
}
}