mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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:
220
lib/utils/advert_helper.dart
Normal file
220
lib/utils/advert_helper.dart
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user