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

@@ -16,6 +16,7 @@ class _AddChannelSheetState extends State<AddChannelSheet> {
final _nameController = TextEditingController();
final _secretController = TextEditingController();
bool _isCreating = false;
bool _obscureSecret = true;
@override
void dispose() {
@@ -203,8 +204,23 @@ class _AddChannelSheetState extends State<AddChannelSheet> {
labelText: l10n.channelSecret,
hintText: l10n.channelSecretHint,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: _isCreating
? null
: () => setState(() {
_obscureSecret = !_obscureSecret;
}),
icon: Icon(
_obscureSecret
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
tooltip: _obscureSecret
? 'Show secret'
: 'Hide secret',
),
),
obscureText: true,
obscureText: _obscureSecret,
enabled: !_isCreating,
maxLength: 32,
validator: _validateSecret,

View File

@@ -120,34 +120,6 @@ class ContactTile extends StatelessWidget {
: null;
void handleTap() => _handlePrimaryTap(context, contact);
final onLongPress = isPingInProgress
? null
: () async {
final connectionProvider = context.read<ConnectionProvider>();
final hasPath = contact.routeHasPath;
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: hasPath,
onRetryWithFlooding: () {
if (context.mounted) {
ToastLogger.warning(
context,
AppLocalizations.of(
context,
)!.directPingTimeout(contact.displayName),
);
}
},
);
if (context.mounted && !result.success) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.pingFailed(contact.displayName),
);
}
};
final colorScheme = Theme.of(context).colorScheme;
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
@@ -233,7 +205,7 @@ class ContactTile extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: handleTap,
onLongPress: onLongPress,
onLongPress: handleTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
@@ -418,6 +390,36 @@ class ContactTile extends StatelessWidget {
_showContactActionSheet(context, contact);
}
Future<void> _smartPingContact(BuildContext context, Contact contact) async {
final connectionProvider = context.read<ConnectionProvider>();
final hasPath = contact.routeHasPath;
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: hasPath,
onRetryWithFlooding: () {
if (context.mounted) {
ToastLogger.warning(
context,
AppLocalizations.of(
context,
)!.directPingTimeout(contact.displayName),
);
}
},
);
if (!context.mounted) return;
if (result.success) {
ToastLogger.success(context, 'Ping reply from ${contact.displayName}');
} else {
ToastLogger.error(
context,
AppLocalizations.of(context)!.pingFailed(contact.displayName),
);
}
}
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
@@ -498,6 +500,11 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.repeater ||
contact.type == ContactType.sensor;
final canPreviewSensor = contact.isSensor;
final canSmartPing =
contact.type == ContactType.chat || contact.type == ContactType.sensor;
final isPingInProgress = context.read<ConnectionProvider>().isPingInProgress(
contact.publicKey,
);
final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
final channelLocationSharingFuture =
@@ -662,6 +669,16 @@ class ContactTile extends StatelessWidget {
_pingRelay(context, contact);
},
),
if (canSmartPing)
_ContactSheetAction(
icon: Icons.network_ping,
label: l10n.ping,
enabled: !isPingInProgress,
onTap: () async {
Navigator.pop(context);
await _smartPingContact(context, contact);
},
),
if (!contact.isPublicChannel)
_ContactSheetAction(
icon: Icons.edit_outlined,
@@ -1596,11 +1613,12 @@ class _ContactActionSheetState extends State<_ContactActionSheet> {
),
),
),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
Material(
color: colorScheme.surfaceContainerLow,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
border: Border.all(
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.28),
),
),

View File

@@ -21,7 +21,6 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
final FocusNode _focusNode = FocusNode();
bool _isLoggingIn = false;
bool _obscurePassword = true;
bool _isDisposed = false; // Track disposal state for async callbacks
@override
void initState() {
@@ -31,7 +30,6 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
@override
void dispose() {
_isDisposed = true;
_passwordController.dispose();
_focusNode.dispose();
super.dispose();
@@ -228,6 +226,15 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
// Save password before sending
await _savePassword(password);
if (!mounted) return;
// Capture references before the sheet pops: the login result arrives
// after Navigator.pop, when this State is disposed, so the callbacks
// below must not depend on this widget's context.
final messenger = ScaffoldMessenger.of(context);
final colorScheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
// Set up login callbacks
Function(Uint8List, int, bool, int)? originalOnSuccess;
Function(Uint8List)? originalOnFail;
@@ -251,12 +258,12 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
' Messages will be fetched when onMessageWaiting callback is triggered',
);
// Check both _isDisposed flag and mounted to handle race conditions
if (_isDisposed || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
// Use the captured messenger: the sheet has already been popped by the
// time this callback fires, so this State's context is gone.
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.loggedInSuccessfully),
backgroundColor: Theme.of(context).colorScheme.primary,
content: Text(l10n.loggedInSuccessfully),
backgroundColor: colorScheme.primary,
duration: const Duration(seconds: 3),
),
);
@@ -269,12 +276,12 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
debugPrint('❌ [RoomLogin] Login failed - incorrect password');
// Check both _isDisposed flag and mounted to handle race conditions
if (_isDisposed || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
// Use the captured messenger: the sheet has already been popped by the
// time this callback fires, so this State's context is gone.
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.loginFailed),
backgroundColor: Theme.of(context).colorScheme.error,
content: Text(l10n.loginFailed),
backgroundColor: colorScheme.error,
duration: const Duration(seconds: 3),
),
);
@@ -289,15 +296,14 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
_focusNode.unfocus();
if (!mounted) return;
Navigator.pop(context); // Close the dialog
if (mounted) {
Navigator.pop(context); // Close the dialog
}
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.loggingIn(widget.contact.displayName),
),
backgroundColor: Theme.of(context).colorScheme.primary,
content: Text(l10n.loggingIn(widget.contact.displayName)),
backgroundColor: colorScheme.primary,
duration: const Duration(seconds: 2),
),
);
@@ -306,13 +312,10 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToSendLogin(e.toString()),
),
backgroundColor: Theme.of(context).colorScheme.error,
content: Text(l10n.failedToSendLogin(e.toString())),
backgroundColor: colorScheme.error,
),
);
} finally {