Merge branch 'main' into issue-fix-channel-edit-delete-actions

This commit is contained in:
just_stuff_tm
2026-02-22 06:40:04 -05:00
committed by GitHub
36 changed files with 552 additions and 166 deletions
+2
View File
@@ -2529,6 +2529,8 @@ class MeshCoreConnector extends ChangeNotifier {
}
final label = channelName ?? _channelDisplayName(channelIndex);
if (_appSettingsService!.isChannelMuted(label)) return;
_notificationService.showChannelMessageNotification(
channelName: label,
message: message.text,
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Публичен канал",
"channels_privateChannel": "Частен канал",
"channels_editChannel": "Редактирай канал",
"channels_muteChannel": "Заглуши канала",
"channels_unmuteChannel": "Включи известията на канала",
"channels_deleteChannel": "Изтрий канала",
"channels_deleteChannelConfirm": "Изтрий \"{name}\"? Това не може да бъде отменено.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Öffentlicher Kanal",
"channels_privateChannel": "Privater Kanal",
"channels_editChannel": "Kanal bearbeiten",
"channels_muteChannel": "Kanal stummschalten",
"channels_unmuteChannel": "Kanal Stummschaltung aufheben",
"channels_deleteChannel": "Lösche den Kanal",
"channels_deleteChannelConfirm": "Löschen von \"{name}\"? Dies kann nicht rückgängig gemacht werden.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -352,6 +352,8 @@
"channels_publicChannel": "Public channel",
"channels_privateChannel": "Private channel",
"channels_editChannel": "Edit channel",
"channels_muteChannel": "Mute channel",
"channels_unmuteChannel": "Unmute channel",
"channels_deleteChannel": "Delete channel",
"channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Canal público",
"channels_privateChannel": "Canal privado",
"channels_editChannel": "Editar canal",
"channels_muteChannel": "Silenciar canal",
"channels_unmuteChannel": "Activar canal",
"channels_deleteChannel": "Eliminar canal",
"channels_deleteChannelConfirm": "Eliminar \"{name}\"? Esto no se puede deshacer.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Canal public",
"channels_privateChannel": "Canal privé",
"channels_editChannel": "Modifier le canal",
"channels_muteChannel": "Désactiver les notifications du canal",
"channels_unmuteChannel": "Réactiver les notifications du canal",
"channels_deleteChannel": "Supprimer le canal",
"channels_deleteChannelConfirm": "Supprimer {name}? Cela ne peut pas être annulé.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Canale pubblico",
"channels_privateChannel": "Canale privato",
"channels_editChannel": "Modifica canale",
"channels_muteChannel": "Silenzia canale",
"channels_unmuteChannel": "Attiva notifiche canale",
"channels_deleteChannel": "Elimina canale",
"channels_deleteChannelConfirm": "Eliminare \"{name}\"? Non può essere annullato.",
"@channels_deleteChannelConfirm": {
+12
View File
@@ -1546,6 +1546,18 @@ abstract class AppLocalizations {
/// **'Edit channel'**
String get channels_editChannel;
/// No description provided for @channels_muteChannel.
///
/// In en, this message translates to:
/// **'Mute channel'**
String get channels_muteChannel;
/// No description provided for @channels_unmuteChannel.
///
/// In en, this message translates to:
/// **'Unmute channel'**
String get channels_unmuteChannel;
/// No description provided for @channels_deleteChannel.
///
/// In en, this message translates to:
+6
View File
@@ -798,6 +798,12 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get channels_editChannel => 'Редактирай канал';
@override
String get channels_muteChannel => 'Заглуши канала';
@override
String get channels_unmuteChannel => 'Включи известията на канала';
@override
String get channels_deleteChannel => 'Изтрий канала';
+6
View File
@@ -795,6 +795,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get channels_editChannel => 'Kanal bearbeiten';
@override
String get channels_muteChannel => 'Kanal stummschalten';
@override
String get channels_unmuteChannel => 'Kanal Stummschaltung aufheben';
@override
String get channels_deleteChannel => 'Lösche den Kanal';
+6
View File
@@ -787,6 +787,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get channels_editChannel => 'Edit channel';
@override
String get channels_muteChannel => 'Mute channel';
@override
String get channels_unmuteChannel => 'Unmute channel';
@override
String get channels_deleteChannel => 'Delete channel';
+6
View File
@@ -796,6 +796,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get channels_editChannel => 'Editar canal';
@override
String get channels_muteChannel => 'Silenciar canal';
@override
String get channels_unmuteChannel => 'Activar canal';
@override
String get channels_deleteChannel => 'Eliminar canal';
+6
View File
@@ -798,6 +798,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get channels_editChannel => 'Modifier le canal';
@override
String get channels_muteChannel => 'Désactiver les notifications du canal';
@override
String get channels_unmuteChannel => 'Réactiver les notifications du canal';
@override
String get channels_deleteChannel => 'Supprimer le canal';
+6
View File
@@ -794,6 +794,12 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get channels_editChannel => 'Modifica canale';
@override
String get channels_muteChannel => 'Silenzia canale';
@override
String get channels_unmuteChannel => 'Attiva notifiche canale';
@override
String get channels_deleteChannel => 'Elimina canale';
+6
View File
@@ -792,6 +792,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get channels_editChannel => 'Kanaal bewerken';
@override
String get channels_muteChannel => 'Kanaal dempen';
@override
String get channels_unmuteChannel => 'Kanaal dempen opheffen';
@override
String get channels_deleteChannel => 'Kanaal verwijderen';
+6
View File
@@ -797,6 +797,12 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get channels_editChannel => 'Edytuj kanał';
@override
String get channels_muteChannel => 'Wycisz kanał';
@override
String get channels_unmuteChannel => 'Wyłącz wyciszenie kanału';
@override
String get channels_deleteChannel => 'Usuń kanał';
+6
View File
@@ -797,6 +797,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get channels_editChannel => 'Editar canal';
@override
String get channels_muteChannel => 'Silenciar canal';
@override
String get channels_unmuteChannel => 'Ativar canal';
@override
String get channels_deleteChannel => 'Excluir canal';
+6
View File
@@ -795,6 +795,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get channels_editChannel => 'Изменить канал';
@override
String get channels_muteChannel => 'Отключить уведомления канала';
@override
String get channels_unmuteChannel => 'Включить уведомления канала';
@override
String get channels_deleteChannel => 'Удалить канал';
+6
View File
@@ -792,6 +792,12 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get channels_editChannel => 'Upraviť kanál';
@override
String get channels_muteChannel => 'Stlmiť kanál';
@override
String get channels_unmuteChannel => 'Zrušiť stlmenie kanála';
@override
String get channels_deleteChannel => 'Odstrániť kanál';
+6
View File
@@ -790,6 +790,12 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get channels_editChannel => 'Uredi kanal';
@override
String get channels_muteChannel => 'Utišaj kanal';
@override
String get channels_unmuteChannel => 'Vklopi obvestila kanala';
@override
String get channels_deleteChannel => 'Pošlji kanal';
+6
View File
@@ -786,6 +786,12 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get channels_editChannel => 'Redigera kanal';
@override
String get channels_muteChannel => 'Tysta kanal';
@override
String get channels_unmuteChannel => 'Slå på ljud för kanal';
@override
String get channels_deleteChannel => 'Ta bort kanal';
+6
View File
@@ -793,6 +793,12 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get channels_editChannel => 'Редагувати канал';
@override
String get channels_muteChannel => 'Вимкнути сповіщення каналу';
@override
String get channels_unmuteChannel => 'Увімкнути сповіщення каналу';
@override
String get channels_deleteChannel => 'Видалити канал';
+6
View File
@@ -755,6 +755,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get channels_editChannel => '编辑频道';
@override
String get channels_muteChannel => '静音频道';
@override
String get channels_unmuteChannel => '取消静音频道';
@override
String get channels_deleteChannel => '删除频道';
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Open kanaal",
"channels_privateChannel": "Private kanaal",
"channels_editChannel": "Kanaal bewerken",
"channels_muteChannel": "Kanaal dempen",
"channels_unmuteChannel": "Kanaal dempen opheffen",
"channels_deleteChannel": "Kanaal verwijderen",
"channels_deleteChannelConfirm": "Verwijderen \"{name}\"? Dit kan niet worden teruggedraaid.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Kanał publiczny",
"channels_privateChannel": "Prywatny kanał",
"channels_editChannel": "Edytuj kanał",
"channels_muteChannel": "Wycisz kanał",
"channels_unmuteChannel": "Wyłącz wyciszenie kanału",
"channels_deleteChannel": "Usuń kanał",
"channels_deleteChannelConfirm": "Usuń \"{name}\"? Nie można tego cofnąć.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Canal público",
"channels_privateChannel": "Canal privado",
"channels_editChannel": "Editar canal",
"channels_muteChannel": "Silenciar canal",
"channels_unmuteChannel": "Ativar canal",
"channels_deleteChannel": "Excluir canal",
"channels_deleteChannelConfirm": "Excluir \"{name}\"? Não pode ser desfeito.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -226,6 +226,8 @@
"channels_publicChannel": "Публичный канал",
"channels_privateChannel": "Приватный канал",
"channels_editChannel": "Изменить канал",
"channels_muteChannel": "Отключить уведомления канала",
"channels_unmuteChannel": "Включить уведомления канала",
"channels_deleteChannel": "Удалить канал",
"channels_deleteChannelConfirm": "Удалить \"{name}\"? Это действие нельзя отменить.",
"channels_channelDeleted": "Канал \"{name}\" удалён",
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Veľké verejne kanály",
"channels_privateChannel": "Osobné kanál",
"channels_editChannel": "Upraviť kanál",
"channels_muteChannel": "Stlmiť kanál",
"channels_unmuteChannel": "Zrušiť stlmenie kanála",
"channels_deleteChannel": "Odstrániť kanál",
"channels_deleteChannelConfirm": "Odstrániť \"{name}\"? To sa nedá zrušiť.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Javni kanal",
"channels_privateChannel": "Zasebni kanal",
"channels_editChannel": "Uredi kanal",
"channels_muteChannel": "Utišaj kanal",
"channels_unmuteChannel": "Vklopi obvestila kanala",
"channels_deleteChannel": "Pošlji kanal",
"channels_deleteChannelConfirm": "Izbrišem \"{name}\"? To se ne da povrniti.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Allmänt kanal",
"channels_privateChannel": "Privat kanal",
"channels_editChannel": "Redigera kanal",
"channels_muteChannel": "Tysta kanal",
"channels_unmuteChannel": "Slå på ljud för kanal",
"channels_deleteChannel": "Ta bort kanal",
"channels_deleteChannelConfirm": "Radera \"{name}\"? Detta kan inte ångras.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -335,6 +335,8 @@
"channels_publicChannel": "Публічний канал",
"channels_privateChannel": "Приватний канал",
"channels_editChannel": "Редагувати канал",
"channels_muteChannel": "Вимкнути сповіщення каналу",
"channels_unmuteChannel": "Увімкнути сповіщення каналу",
"channels_deleteChannel": "Видалити канал",
"channels_deleteChannelConfirm": "Видалити {name}? Це не можна скасувати.",
"@channels_deleteChannelConfirm": {
+2
View File
@@ -342,6 +342,8 @@
"channels_publicChannel": "公共频道",
"channels_privateChannel": "私密频道",
"channels_editChannel": "编辑频道",
"channels_muteChannel": "静音频道",
"channels_unmuteChannel": "取消静音频道",
"channels_deleteChannel": "删除频道",
"channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.",
"@channels_deleteChannelConfirm": {
+12 -1
View File
@@ -36,6 +36,7 @@ class AppSettings {
final Map<String, String> batteryChemistryByDeviceId;
final Map<String, String> batteryChemistryByRepeaterId;
final UnitSystem unitSystem;
final Set<String> mutedChannels;
AppSettings({
this.clearPathOnMaxRetry = false,
@@ -60,8 +61,10 @@ class AppSettings {
Map<String, String>? batteryChemistryByDeviceId,
Map<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {};
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {};
Map<String, dynamic> toJson() {
return {
@@ -87,6 +90,7 @@ class AppSettings {
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(),
};
}
@@ -134,6 +138,11 @@ class AppSettings {
) ??
{},
unitSystem: parseUnitSystem(json['unit_system']),
mutedChannels:
((json['muted_channels'] as List?)
?.map((e) => e.toString())
.toSet()) ??
{},
);
}
@@ -160,6 +169,7 @@ class AppSettings {
Map<String, String>? batteryChemistryByDeviceId,
Map<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem,
Set<String>? mutedChannels,
}) {
return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@@ -192,6 +202,7 @@ class AppSettings {
batteryChemistryByRepeaterId:
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
unitSystem: unitSystem ?? this.unitSystem,
mutedChannels: mutedChannels ?? this.mutedChannels,
);
}
}
+367 -165
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@@ -271,193 +272,243 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
? message.pathVariants.first
: Uint8List(0));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
_buildAvatar(message.senderName),
const SizedBox(width: 8),
],
Flexible(
child: GestureDetector(
onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
child: Container(
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
top: 4,
bottom: 4,
)
: EdgeInsets.zero,
child: Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
const maxSwipeOffset = 64.0;
const replySwipeThreshold = 64.0;
final messageBody = Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
_buildAvatar(message.senderName),
const SizedBox(width: 8),
],
Flexible(
child: GestureDetector(
onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
child: Container(
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
top: 4,
bottom: 4,
)
: EdgeInsets.zero,
child: Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
if (gifId == null) const SizedBox(height: 4),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message),
const SizedBox(height: 8),
],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
),
)
else
Linkify(
text: message.text,
style: const TextStyle(fontSize: 14),
linkStyle: const TextStyle(
fontSize: 14,
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) =>
LinkHandler.handleLinkTap(context, link.url),
),
if (gifId == null) const SizedBox(height: 4),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message),
const SizedBox(height: 8),
],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
),
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
)
else
Linkify(
text: message.text,
style: const TextStyle(fontSize: 14),
linkStyle: const TextStyle(
fontSize: 14,
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) =>
LinkHandler.handleLinkTap(context, link.url),
),
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12,
color: Colors.grey[600],
),
const SizedBox(width: 2),
Text(
_formatTime(message.timestamp),
'${message.repeatCount}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12,
color: Colors.grey[600],
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status ==
ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color:
message.status ==
ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status ==
ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color:
message.status ==
ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
],
),
),
],
),
),
),
],
),
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(message),
),
],
),
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(message),
),
],
),
],
);
if (!isOutgoing) {
return _SwipeReplyBubble(
maxSwipeOffset: maxSwipeOffset,
replySwipeThreshold: replySwipeThreshold,
onReplyTriggered: () => _setReplyingTo(message),
hintBuilder: ({required isStart}) =>
_buildReplySwipeHint(isStart: isStart),
child: messageBody,
);
} else {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: messageBody,
);
}
}
Widget _buildReplySwipeHint({required bool isStart}) {
final colorScheme = Theme.of(context).colorScheme;
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.reply, color: colorScheme.primary),
const SizedBox(width: 6),
Text(
context.l10n.chat_reply,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
);
return Container(
alignment: isStart ? Alignment.centerLeft : Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: colorScheme.primary.withValues(alpha: 0.08),
child: isStart
? content
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.chat_reply,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Icon(Icons.reply, color: colorScheme.primary),
],
),
);
}
@@ -1007,6 +1058,157 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
}
class _SwipeReplyBubble extends StatefulWidget {
final double maxSwipeOffset;
final double replySwipeThreshold;
final VoidCallback onReplyTriggered;
final Widget Function({required bool isStart}) hintBuilder;
final Widget child;
const _SwipeReplyBubble({
required this.maxSwipeOffset,
required this.replySwipeThreshold,
required this.onReplyTriggered,
required this.hintBuilder,
required this.child,
});
@override
State<_SwipeReplyBubble> createState() => _SwipeReplyBubbleState();
}
class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
Offset? _swipeStartPosition;
double _swipeOffset = 0;
double _maxSwipeDistance = 0;
int? _swipePointerId;
bool _swipeLockedToHorizontal = false;
void _handleSwipeStart(Offset position) {
_swipeStartPosition = position;
_maxSwipeDistance = 0;
if (_swipeOffset != 0) {
setState(() => _swipeOffset = 0);
}
}
void _handleSwipePointerDown(PointerDownEvent event) {
_swipePointerId = event.pointer;
_swipeLockedToHorizontal = false;
_handleSwipeStart(event.position);
}
void _handleSwipePointerMove(PointerMoveEvent event) {
if (_swipePointerId != event.pointer || _swipeStartPosition == null) {
return;
}
final dx = event.position.dx - _swipeStartPosition!.dx;
const axisLockThreshold = 12.0;
if (!_swipeLockedToHorizontal) {
if (-dx < axisLockThreshold) {
return;
}
_swipeLockedToHorizontal = true;
}
_handleSwipeUpdate(event.position);
}
void _handleSwipeUpdate(Offset position) {
if (_swipeStartPosition == null) return;
final dx = position.dx - _swipeStartPosition!.dx;
if (dx >= 0) return;
if (-dx < 6) return;
if (-dx > _maxSwipeDistance) {
_maxSwipeDistance = -dx;
}
final double clamped = dx.clamp(-widget.maxSwipeOffset, 0.0).toDouble();
final adjusted = _applySwipeResistance(clamped, widget.maxSwipeOffset);
if (adjusted != _swipeOffset) {
setState(() => _swipeOffset = adjusted);
}
}
void _handleSwipePointerUp(Offset position) {
if (_swipeLockedToHorizontal && _swipeStartPosition != null) {
final dx = position.dx - _swipeStartPosition!.dx;
final peak = math.max(
_maxSwipeDistance,
(-dx).clamp(0.0, double.infinity),
);
if (peak >= widget.replySwipeThreshold) {
widget.onReplyTriggered();
HapticFeedback.selectionClick();
}
}
_resetSwipe();
}
void _resetSwipe() {
if (_swipeOffset != 0) {
setState(() => _swipeOffset = 0);
}
_swipeStartPosition = null;
_maxSwipeDistance = 0;
_swipePointerId = null;
_swipeLockedToHorizontal = false;
}
double _applySwipeResistance(double rawOffset, double maxOffset) {
final abs = rawOffset.abs();
if (abs <= 0) return 0;
final norm = (abs / maxOffset).clamp(0.0, 1.0);
const deadZone = 0.18;
if (norm <= deadZone) {
return rawOffset.sign * maxOffset * (norm * 0.08);
}
final t = ((norm - deadZone) / (1 - deadZone)).clamp(0.0, 1.0);
final curved = t < 0.5
? 16 * math.pow(t, 5)
: 1 - math.pow(-2 * t + 2, 5) / 2;
const deadZoneEnd = 0.0144;
return rawOffset.sign *
maxOffset *
(deadZoneEnd + curved * (1 - deadZoneEnd));
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: _handleSwipePointerDown,
onPointerMove: _handleSwipePointerMove,
onPointerUp: (event) => _handleSwipePointerUp(event.position),
onPointerCancel: (_) => _resetSwipe(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Opacity(
opacity: _swipeOffset.abs() / widget.maxSwipeOffset,
child: widget.hintBuilder(isStart: false),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 150),
transform: Matrix4.translationValues(_swipeOffset, 0, 0),
curve: Curves.easeOut,
child: widget.child,
),
],
),
),
);
}
}
class _PoiInfo {
final double lat;
final double lon;
+24
View File
@@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../models/channel.dart';
import '../models/community.dart';
import '../storage/community_store.dart';
@@ -478,6 +479,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
Channel channel,
) {
final parentContext = context;
final settingsService = context.read<AppSettingsService>();
final isMuted = settingsService.isChannelMuted(channel.name);
showModalBottomSheet(
context: parentContext,
builder: (sheetContext) => SafeArea(
@@ -495,6 +499,26 @@ class _ChannelsScreenState extends State<ChannelsScreen>
}
},
),
ListTile(
leading: Icon(
isMuted
? Icons.notifications_outlined
: Icons.notifications_off_outlined,
),
title: Text(
isMuted
? context.l10n.channels_unmuteChannel
: context.l10n.channels_muteChannel,
),
onTap: () async {
Navigator.pop(context);
if (isMuted) {
await settingsService.unmuteChannel(channel.name);
} else {
await settingsService.muteChannel(channel.name);
}
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: Text(
+15
View File
@@ -155,4 +155,19 @@ class AppSettingsService extends ChangeNotifier {
Future<void> setUnitSystem(UnitSystem value) async {
await updateSettings(_settings.copyWith(unitSystem: value));
}
bool isChannelMuted(String channelName) {
return _settings.mutedChannels.contains(channelName);
}
Future<void> muteChannel(String channelName) async {
final updated = Set<String>.from(_settings.mutedChannels)..add(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated));
}
Future<void> unmuteChannel(String channelName) async {
final updated = Set<String>.from(_settings.mutedChannels)
..remove(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated));
}
}