mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
format dart files
formats all dart files using `dart format .` from the root project dir this makes the code style repeatable by new contributors and makes PR review easier
This commit is contained in:
parent
488a286701
commit
b34d684e67
66 changed files with 2882 additions and 1848 deletions
|
|
@ -1286,9 +1286,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
if (reactionInfo != null) {
|
||||
// Check if we've already processed this reaction
|
||||
_processedChannelReactions.putIfAbsent(channel.index, () => {});
|
||||
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}';
|
||||
final reactionIdentifier =
|
||||
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
|
||||
|
||||
if (_processedChannelReactions[channel.index]!.contains(reactionIdentifier)) {
|
||||
if (_processedChannelReactions[channel.index]!.contains(
|
||||
reactionIdentifier,
|
||||
)) {
|
||||
// Already processed, don't process again
|
||||
return;
|
||||
}
|
||||
|
|
@ -1504,7 +1507,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
|
||||
// Skip fetching if already loaded and not forced
|
||||
if (_hasLoadedChannels && !force) {
|
||||
debugPrint('[ChannelSync] Channels already loaded, skipping fetch (use force=true to reload)');
|
||||
debugPrint(
|
||||
'[ChannelSync] Channels already loaded, skipping fetch (use force=true to reload)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2696,10 +2701,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
if (reactionInfo != null) {
|
||||
// Check if we've already processed this exact reaction
|
||||
_processedContactReactions.putIfAbsent(pubKeyHex, () => {});
|
||||
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}';
|
||||
final reactionIdentifier =
|
||||
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
|
||||
|
||||
final isDuplicate =
|
||||
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier);
|
||||
final isDuplicate = _processedContactReactions[pubKeyHex]!.contains(
|
||||
reactionIdentifier,
|
||||
);
|
||||
|
||||
if (!isDuplicate) {
|
||||
// New reaction - process it
|
||||
|
|
@ -2734,20 +2741,22 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
|
||||
for (int i = messages.length - 1; i >= 0; i--) {
|
||||
final msg = messages[i];
|
||||
|
||||
|
||||
// For 1:1 chats: contact reacts to my outgoing messages only
|
||||
// For room servers: any message can be reacted to (multi-user)
|
||||
if (!isRoomServer && !msg.isOutgoing) continue;
|
||||
|
||||
|
||||
final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
|
||||
// For room servers, include sender name (resolve from fourByteRoomContactKey)
|
||||
// For 1:1 chats, sender is implicit (null)
|
||||
String? senderName;
|
||||
if (isRoomServer && !msg.isOutgoing) {
|
||||
// Resolve sender from the message's fourByteRoomContactKey
|
||||
final senderContact = _contacts.cast<Contact?>().firstWhere(
|
||||
(c) => c != null && _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
|
||||
(c) =>
|
||||
c != null &&
|
||||
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
|
||||
orElse: () => null,
|
||||
);
|
||||
senderName = senderContact?.name;
|
||||
|
|
@ -2755,7 +2764,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
senderName = selfName;
|
||||
}
|
||||
// For 1:1, senderName stays null
|
||||
|
||||
|
||||
final msgHash = ReactionHelper.computeReactionHash(
|
||||
timestampSecs,
|
||||
senderName,
|
||||
|
|
@ -2919,10 +2928,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
if (reactionInfo != null) {
|
||||
// Check if we've already processed this exact reaction
|
||||
_processedChannelReactions.putIfAbsent(channelIndex, () => {});
|
||||
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}';
|
||||
final reactionIdentifier =
|
||||
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
|
||||
|
||||
final isDuplicate =
|
||||
_processedChannelReactions[channelIndex]!.contains(reactionIdentifier);
|
||||
final isDuplicate = _processedChannelReactions[channelIndex]!.contains(
|
||||
reactionIdentifier,
|
||||
);
|
||||
|
||||
if (!isDuplicate) {
|
||||
// New reaction - process it
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ class BufferWriter {
|
|||
final hexByte = hex.substring(i * 2, i * 2 + 2);
|
||||
final byte = int.tryParse(hexByte, radix: 16);
|
||||
if (byte == null) {
|
||||
throw FormatException('Invalid hex characters at position $i: $hexByte');
|
||||
throw FormatException(
|
||||
'Invalid hex characters at position $i: $hexByte',
|
||||
);
|
||||
}
|
||||
result.add(byte);
|
||||
}
|
||||
|
|
@ -219,8 +221,10 @@ const int maxFrameSize = 172;
|
|||
const int appProtocolVersion = 3;
|
||||
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
|
||||
const int maxTextPayloadBytes = 160;
|
||||
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
|
||||
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
|
||||
const int _sendTextMsgOverheadBytes =
|
||||
1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
|
||||
const int _sendChannelTextMsgOverheadBytes =
|
||||
1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
|
||||
|
||||
int maxContactMessageBytes() {
|
||||
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
|
||||
|
|
@ -735,8 +739,7 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
|
|||
|
||||
//Build a trace request frame
|
||||
//[cmd][tag x4][auth x4][flag][payload]
|
||||
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload})
|
||||
{
|
||||
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTracePath);
|
||||
writer.writeUInt32LE(tag);
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ class CayenneLpp {
|
|||
static const int lppUnixTime = 133; // 4 bytes, unsigned
|
||||
static const int lppGyrometer = 134; // 2 bytes per axis, 0.01 °/s
|
||||
static const int lppColour = 135; // 1 byte per RGB Color
|
||||
static const int lppGps = 136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter
|
||||
static const int lppGps =
|
||||
136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter
|
||||
static const int lppSwitch = 142; // 1 byte, 0/1
|
||||
static const int lppPolyline = 240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas
|
||||
static const int lppPolyline =
|
||||
240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas
|
||||
|
||||
final BufferWriter _writer = BufferWriter();
|
||||
|
||||
|
|
@ -201,10 +203,10 @@ class CayenneLpp {
|
|||
break;
|
||||
}
|
||||
|
||||
final channelData = channels.putIfAbsent(channel, () => {
|
||||
'channel': channel,
|
||||
'values': <String, dynamic>{},
|
||||
});
|
||||
final channelData = channels.putIfAbsent(
|
||||
channel,
|
||||
() => {'channel': channel, 'values': <String, dynamic>{}},
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case lppGenericSensor:
|
||||
|
|
@ -254,8 +256,8 @@ class CayenneLpp {
|
|||
}
|
||||
}
|
||||
|
||||
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
|
||||
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
|
||||
return channelsOut;
|
||||
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
|
||||
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
|
||||
return channelsOut;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,7 @@ class LinkHandler {
|
|||
),
|
||||
child: SelectableText(
|
||||
url,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ class ReactionInfo {
|
|||
final String targetHash;
|
||||
final String emoji;
|
||||
|
||||
ReactionInfo({
|
||||
required this.targetHash,
|
||||
required this.emoji,
|
||||
});
|
||||
ReactionInfo({required this.targetHash, required this.emoji});
|
||||
}
|
||||
|
||||
class ReactionHelper {
|
||||
|
|
@ -42,7 +39,11 @@ class ReactionHelper {
|
|||
/// Compute a 4-char hex hash for a message reaction.
|
||||
/// Hash input: timestampSeconds + [senderName] + first 5 chars of text
|
||||
/// For 1:1 chats, senderName can be null (sender is implicit).
|
||||
static String computeReactionHash(int timestampSeconds, String? senderName, String text) {
|
||||
static String computeReactionHash(
|
||||
int timestampSeconds,
|
||||
String? senderName,
|
||||
String text,
|
||||
) {
|
||||
final first5 = text.length >= 5 ? text.substring(0, 5) : text;
|
||||
final input = senderName != null
|
||||
? '$timestampSeconds$senderName$first5'
|
||||
|
|
@ -62,9 +63,6 @@ class ReactionHelper {
|
|||
final emoji = indexToEmoji(match.group(2)!);
|
||||
if (emoji == null) return null;
|
||||
|
||||
return ReactionInfo(
|
||||
targetHash: match.group(1)!,
|
||||
emoji: emoji,
|
||||
);
|
||||
return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,8 +262,9 @@ class Smaz {
|
|||
".com",
|
||||
];
|
||||
|
||||
static final List<Uint8List> _rcbBytes =
|
||||
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false);
|
||||
static final List<Uint8List> _rcbBytes = _rcb
|
||||
.map((s) => Uint8List.fromList(ascii.encode(s)))
|
||||
.toList(growable: false);
|
||||
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
|
||||
return entry.length > maxLen ? entry.length : maxLen;
|
||||
});
|
||||
|
|
@ -358,24 +359,32 @@ class Smaz {
|
|||
final code = input[index];
|
||||
if (code == _verbatimSingle) {
|
||||
if (index + 1 >= input.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim byte.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim byte.',
|
||||
);
|
||||
}
|
||||
out.addByte(input[index + 1]);
|
||||
index += 2;
|
||||
} else if (code == _verbatimRun) {
|
||||
if (index + 1 >= input.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim length.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim length.',
|
||||
);
|
||||
}
|
||||
final len = input[index + 1] + 1;
|
||||
final end = index + 2 + len;
|
||||
if (end > input.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim run.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim run.',
|
||||
);
|
||||
}
|
||||
out.add(input.sublist(index + 2, end));
|
||||
index = end;
|
||||
} else {
|
||||
if (code >= _rcbBytes.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: code out of range.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: code out of range.',
|
||||
);
|
||||
}
|
||||
out.add(_rcbBytes[code]);
|
||||
index += 1;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
|
|||
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (maxBytes <= 0) return oldValue;
|
||||
final bytes = utf8.encode(newValue.text);
|
||||
if (bytes.length <= maxBytes) return newValue;
|
||||
|
|
|
|||
|
|
@ -65,16 +65,18 @@ void main() async {
|
|||
await connector.loadAllChannelMessages();
|
||||
await connector.loadUnreadState();
|
||||
|
||||
runApp(MeshCoreApp(
|
||||
connector: connector,
|
||||
retryService: retryService,
|
||||
pathHistoryService: pathHistoryService,
|
||||
storage: storage,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
));
|
||||
runApp(
|
||||
MeshCoreApp(
|
||||
connector: connector,
|
||||
retryService: retryService,
|
||||
pathHistoryService: pathHistoryService,
|
||||
storage: storage,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MeshCoreApp extends StatelessWidget {
|
||||
|
|
@ -124,7 +126,9 @@ class MeshCoreApp extends StatelessWidget {
|
|||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: _localeFromSetting(settingsService.settings.languageOverride),
|
||||
locale: _localeFromSetting(
|
||||
settingsService.settings.languageOverride,
|
||||
),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
|
|
@ -142,7 +146,9 @@ class MeshCoreApp extends StatelessWidget {
|
|||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
),
|
||||
themeMode: _themeModeFromSetting(settingsService.settings.themeMode),
|
||||
themeMode: _themeModeFromSetting(
|
||||
settingsService.settings.themeMode,
|
||||
),
|
||||
home: const ScannerScreen(),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -76,13 +76,14 @@ class AppSettings {
|
|||
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
|
||||
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
|
||||
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
|
||||
mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||
mapTimeFilterHours:
|
||||
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
|
||||
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
|
||||
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
|
||||
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
|
||||
mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
|
||||
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
|
||||
|
|
@ -90,11 +91,13 @@ class AppSettings {
|
|||
notifyOnNewChannelMessage:
|
||||
json['notify_on_new_channel_message'] as bool? ?? true,
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
autoRouteRotationEnabled:
|
||||
json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
themeMode: json['theme_mode'] as String? ?? 'system',
|
||||
languageOverride: json['language_override'] as String?,
|
||||
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
|
||||
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
|
||||
batteryChemistryByDeviceId:
|
||||
(json['battery_chemistry_by_device_id'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||
) ??
|
||||
{},
|
||||
|
|
@ -132,8 +135,9 @@ class AppSettings {
|
|||
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
|
||||
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
|
||||
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
|
||||
mapCacheBounds:
|
||||
mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map<String, double>?,
|
||||
mapCacheBounds: mapCacheBounds == _unset
|
||||
? this.mapCacheBounds
|
||||
: mapCacheBounds as Map<String, double>?,
|
||||
mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
|
||||
mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
|
||||
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||
|
|
@ -141,12 +145,15 @@ class AppSettings {
|
|||
notifyOnNewChannelMessage:
|
||||
notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
|
||||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
autoRouteRotationEnabled:
|
||||
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
languageOverride:
|
||||
languageOverride == _unset ? this.languageOverride : languageOverride as String?,
|
||||
languageOverride: languageOverride == _unset
|
||||
? this.languageOverride
|
||||
: languageOverride as String?,
|
||||
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
|
||||
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||
batteryChemistryByDeviceId:
|
||||
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,7 @@ class Channel {
|
|||
final String name;
|
||||
final Uint8List psk; // 16 bytes
|
||||
|
||||
Channel({
|
||||
required this.index,
|
||||
required this.name,
|
||||
required this.psk,
|
||||
});
|
||||
Channel({required this.index, required this.name, required this.psk});
|
||||
|
||||
String get pskHex => _bytesToHex(psk);
|
||||
|
||||
|
|
@ -39,11 +35,7 @@ class Channel {
|
|||
}
|
||||
|
||||
static Channel empty(int index) {
|
||||
return Channel(
|
||||
index: index,
|
||||
name: '',
|
||||
psk: Uint8List(16),
|
||||
);
|
||||
return Channel(index: index, name: '', psk: Uint8List(16));
|
||||
}
|
||||
|
||||
static Channel fromHex(int index, String name, String pskHex) {
|
||||
|
|
|
|||
|
|
@ -59,15 +59,18 @@ class ChannelMessage {
|
|||
this.replyToSenderName,
|
||||
this.replyToText,
|
||||
Map<String, int>? reactions,
|
||||
}) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
|
||||
reactions = reactions ?? {},
|
||||
pathBytes = pathBytes ?? Uint8List(0),
|
||||
pathVariants = _mergePathVariants(
|
||||
pathBytes ?? Uint8List(0),
|
||||
pathVariants,
|
||||
);
|
||||
}) : messageId =
|
||||
messageId ??
|
||||
'${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
|
||||
reactions = reactions ?? {},
|
||||
pathBytes = pathBytes ?? Uint8List(0),
|
||||
pathVariants = _mergePathVariants(
|
||||
pathBytes ?? Uint8List(0),
|
||||
pathVariants,
|
||||
);
|
||||
|
||||
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
|
||||
String? get senderKeyHex =>
|
||||
senderKey != null ? pubKeyToHex(senderKey!) : null;
|
||||
|
||||
ChannelMessage copyWith({
|
||||
ChannelMessageStatus? status,
|
||||
|
|
@ -125,8 +128,10 @@ class ChannelMessage {
|
|||
final hasPathBytesFlag = (data[2] & 0x01) != 0;
|
||||
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
|
||||
final hasValidTxtType =
|
||||
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
|
||||
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) {
|
||||
cursor < data.length &&
|
||||
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
|
||||
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
|
||||
canFitPath) {
|
||||
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
|
||||
cursor += pathLen;
|
||||
}
|
||||
|
|
@ -162,7 +167,8 @@ class ChannelMessage {
|
|||
final potentialSender = text.substring(0, colonIndex);
|
||||
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
|
||||
senderName = potentialSender;
|
||||
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
||||
final offset =
|
||||
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
||||
? colonIndex + 2
|
||||
: colonIndex + 1;
|
||||
actualText = text.substring(offset);
|
||||
|
|
@ -184,7 +190,11 @@ class ChannelMessage {
|
|||
);
|
||||
}
|
||||
|
||||
static ChannelMessage outgoing(String text, String senderName, int channelIndex) {
|
||||
static ChannelMessage outgoing(
|
||||
String text,
|
||||
String senderName,
|
||||
int channelIndex,
|
||||
) {
|
||||
return ChannelMessage(
|
||||
senderKey: null,
|
||||
senderName: senderName,
|
||||
|
|
@ -249,8 +259,5 @@ class ReplyInfo {
|
|||
final String mentionedNode;
|
||||
final String actualMessage;
|
||||
|
||||
ReplyInfo({
|
||||
required this.mentionedNode,
|
||||
required this.actualMessage,
|
||||
});
|
||||
ReplyInfo({required this.mentionedNode, required this.actualMessage});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,7 @@ class Community {
|
|||
}) : hashtagChannels = hashtagChannels ?? [];
|
||||
|
||||
/// Generate a new community with a random 32-byte secret
|
||||
factory Community.create({
|
||||
required String id,
|
||||
required String name,
|
||||
}) {
|
||||
factory Community.create({required String id, required String name}) {
|
||||
final random = Random.secure();
|
||||
final secret = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
|
|
@ -84,7 +81,8 @@ class Community {
|
|||
name: json['name'] as String,
|
||||
secret: base64Decode(json['secret'] as String),
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(json['created_at'] as int),
|
||||
hashtagChannels: (json['hashtag_channels'] as List<dynamic>?)
|
||||
hashtagChannels:
|
||||
(json['hashtag_channels'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
|
|
@ -234,9 +232,7 @@ class Community {
|
|||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Community &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
other is Community && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ class Contact {
|
|||
final int type;
|
||||
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
|
||||
final Uint8List path; // Path bytes from device
|
||||
final int? pathOverride; // User's path override: -1 = force flood, null = auto
|
||||
final int?
|
||||
pathOverride; // User's path override: -1 = force flood, null = auto
|
||||
final Uint8List? pathOverrideBytes; // User's path override bytes
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
|
@ -78,8 +79,12 @@ class Contact {
|
|||
type: type ?? this.type,
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
path: path ?? this.path,
|
||||
pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride),
|
||||
pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes),
|
||||
pathOverride: clearPathOverride
|
||||
? null
|
||||
: (pathOverride ?? this.pathOverride),
|
||||
pathOverrideBytes: clearPathOverride
|
||||
? null
|
||||
: (pathOverrideBytes ?? this.pathOverrideBytes),
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
|
|
@ -93,10 +98,14 @@ class Contact {
|
|||
final parts = <String>[];
|
||||
final groupSize = pathHashSize;
|
||||
for (int i = 0; i < pathBytes.length; i += groupSize) {
|
||||
final end = (i + groupSize) <= pathBytes.length ? (i + groupSize) : pathBytes.length;
|
||||
final end = (i + groupSize) <= pathBytes.length
|
||||
? (i + groupSize)
|
||||
: pathBytes.length;
|
||||
final chunk = pathBytes.sublist(i, end);
|
||||
parts.add(
|
||||
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
|
||||
chunk
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(),
|
||||
);
|
||||
}
|
||||
return parts.join(',');
|
||||
|
|
@ -110,32 +119,32 @@ class Contact {
|
|||
final pathBytes = _pathBytesForDisplay;
|
||||
Uint8List? traceBytes;
|
||||
|
||||
if(pathLength <= 0) {
|
||||
if (pathLength <= 0) {
|
||||
traceBytes = Uint8List(1);
|
||||
traceBytes[0] = publicKey[0];
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
if(type == advTypeRepeater || type == advTypeRoom) {
|
||||
if (type == advTypeRepeater || type == advTypeRoom) {
|
||||
final len = (pathBytes.length + pathBytes.length + 1);
|
||||
traceBytes = Uint8List(len);
|
||||
traceBytes[pathBytes.length] = publicKey[0];
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length) {
|
||||
traceBytes[len-1-i] = pathBytes[i];
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if(pathBytes.length < 2) {
|
||||
if (pathBytes.length < 2) {
|
||||
return pathBytes[0] == 0 ? null : pathBytes;
|
||||
}
|
||||
final len = (pathBytes.length + pathBytes.length-1);
|
||||
final len = (pathBytes.length + pathBytes.length - 1);
|
||||
traceBytes = Uint8List(len);
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length-1) {
|
||||
traceBytes[len-1-i] = pathBytes[i];
|
||||
if (i < pathBytes.length - 1) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,9 @@ class ContactGroup {
|
|||
final String name;
|
||||
final List<String> memberKeys;
|
||||
|
||||
const ContactGroup({
|
||||
required this.name,
|
||||
required this.memberKeys,
|
||||
});
|
||||
const ContactGroup({required this.name, required this.memberKeys});
|
||||
|
||||
ContactGroup copyWith({
|
||||
String? name,
|
||||
List<String>? memberKeys,
|
||||
}) {
|
||||
ContactGroup copyWith({String? name, List<String>? memberKeys}) {
|
||||
return ContactGroup(
|
||||
name: name ?? this.name,
|
||||
memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
|
||||
|
|
@ -18,16 +12,12 @@ class ContactGroup {
|
|||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'members': memberKeys,
|
||||
};
|
||||
return {'name': name, 'members': memberKeys};
|
||||
}
|
||||
|
||||
factory ContactGroup.fromJson(Map<String, dynamic> json) {
|
||||
final members = (json['members'] as List?)
|
||||
?.map((value) => value.toString())
|
||||
.toList() ??
|
||||
final members =
|
||||
(json['members'] as List?)?.map((value) => value.toString()).toList() ??
|
||||
<String>[];
|
||||
return ContactGroup(
|
||||
name: json['name'] as String? ?? '',
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ class Message {
|
|||
Uint8List? pathBytes,
|
||||
Uint8List? fourByteRoomContactKey,
|
||||
Map<String, int>? reactions,
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
|
||||
reactions = reactions ?? {};
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
|
||||
reactions = reactions ?? {};
|
||||
|
||||
String get senderKeyHex => pubKeyToHex(senderKey);
|
||||
|
||||
|
|
@ -80,7 +80,8 @@ class Message {
|
|||
pathLength: pathLength ?? this.pathLength,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
reactions: reactions ?? this.reactions,
|
||||
fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
||||
fourByteRoomContactKey:
|
||||
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ class PathRecord {
|
|||
tripTimeMs: json['trip_time_ms'] as int,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
wasFloodDiscovery: json['was_flood'] as bool,
|
||||
pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||
pathBytes:
|
||||
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||
successCount: json['success_count'] as int? ?? 0,
|
||||
failureCount: json['failure_count'] as int? ?? 0,
|
||||
);
|
||||
|
|
@ -65,14 +66,15 @@ class ContactPathHistory {
|
|||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
|
||||
};
|
||||
return {'recent_paths': recentPaths.map((p) => p.toJson()).toList()};
|
||||
}
|
||||
|
||||
factory ContactPathHistory.fromJson(
|
||||
String contactPubKeyHex, Map<String, dynamic> json) {
|
||||
final pathsList = (json['recent_paths'] as List?)
|
||||
String contactPubKeyHex,
|
||||
Map<String, dynamic> json,
|
||||
) {
|
||||
final pathsList =
|
||||
(json['recent_paths'] as List?)
|
||||
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
|
|
|||
|
|
@ -61,44 +61,44 @@ class RadioSettings {
|
|||
|
||||
// Preset configurations
|
||||
static RadioSettings get preset915MHz => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get preset868MHz => RadioSettings(
|
||||
frequencyMHz: 868.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
);
|
||||
frequencyMHz: 868.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
);
|
||||
|
||||
static RadioSettings get preset433MHz => RadioSettings(
|
||||
frequencyMHz: 433.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
frequencyMHz: 433.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get presetLongRange => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf12,
|
||||
codingRate: LoRaCodingRate.cr4_8,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf12,
|
||||
codingRate: LoRaCodingRate.cr4_8,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get presetFastSpeed => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw500,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw500,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
int get frequencyHz => (frequencyMHz * 1000).round();
|
||||
int get bandwidthHz => bandwidth.hz;
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ class AppDebugLogScreen extends StatelessWidget {
|
|||
onPressed: hasEntries
|
||||
? () async {
|
||||
final text = entries
|
||||
.map((entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
|
||||
.map(
|
||||
(entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
|
||||
)
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
|
|
@ -61,11 +63,17 @@ class AppDebugLogScreen extends StatelessWidget {
|
|||
leading: _buildLevelIcon(entry.level),
|
||||
title: Text(
|
||||
'[${entry.tag}] ${entry.message}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.formattedTime,
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -74,16 +82,26 @@ class AppDebugLogScreen extends StatelessWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.bug_report_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.debugLog_noEntries,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.debugLog_enableInSettings,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -99,7 +117,11 @@ class AppDebugLogScreen extends StatelessWidget {
|
|||
case AppDebugLogLevel.info:
|
||||
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
|
||||
case AppDebugLogLevel.warning:
|
||||
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange);
|
||||
return const Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: Colors.orange,
|
||||
);
|
||||
case AppDebugLogLevel.error:
|
||||
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,10 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildAppearanceCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -58,7 +61,9 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
ListTile(
|
||||
leading: const Icon(Icons.brightness_6_outlined),
|
||||
title: Text(context.l10n.appSettings_theme),
|
||||
subtitle: Text(_themeModeLabel(context, settingsService.settings.themeMode)),
|
||||
subtitle: Text(
|
||||
_themeModeLabel(context, settingsService.settings.themeMode),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showThemeModeDialog(context, settingsService),
|
||||
),
|
||||
|
|
@ -66,7 +71,12 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
ListTile(
|
||||
leading: const Icon(Icons.language_outlined),
|
||||
title: Text(context.l10n.appSettings_language),
|
||||
subtitle: Text(_languageLabel(context, settingsService.settings.languageOverride)),
|
||||
subtitle: Text(
|
||||
_languageLabel(
|
||||
context,
|
||||
settingsService.settings.languageOverride,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showLanguageDialog(context, settingsService),
|
||||
),
|
||||
|
|
@ -75,7 +85,10 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildNotificationsCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -90,17 +103,22 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
SwitchListTile(
|
||||
secondary: const Icon(Icons.notifications_outlined),
|
||||
title: Text(context.l10n.appSettings_enableNotifications),
|
||||
subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_enableNotificationsSubtitle,
|
||||
),
|
||||
value: settingsService.settings.notificationsEnabled,
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
// Request permission when enabling
|
||||
final granted = await NotificationService().requestPermissions();
|
||||
final granted = await NotificationService()
|
||||
.requestPermissions();
|
||||
if (!granted) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.appSettings_notificationPermissionDenied),
|
||||
content: Text(
|
||||
context.l10n.appSettings_notificationPermissionDenied,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
@ -113,9 +131,11 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_notificationsEnabled
|
||||
: context.l10n.appSettings_notificationsDisabled),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_notificationsEnabled
|
||||
: context.l10n.appSettings_notificationsDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
@ -126,18 +146,24 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.message_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.appSettings_messageNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_messageNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewMessage,
|
||||
|
|
@ -151,18 +177,24 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.forum_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.appSettings_channelMessageNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_channelMessageNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewChannelMessage,
|
||||
|
|
@ -176,18 +208,24 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.cell_tower,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.appSettings_advertisementNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_advertisementNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewAdvert,
|
||||
|
|
@ -202,7 +240,10 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildMessagingCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -217,15 +258,19 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
SwitchListTile(
|
||||
secondary: const Icon(Icons.refresh_outlined),
|
||||
title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
|
||||
subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_clearPathOnMaxRetrySubtitle,
|
||||
),
|
||||
value: settingsService.settings.clearPathOnMaxRetry,
|
||||
onChanged: (value) {
|
||||
settingsService.setClearPathOnMaxRetry(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_pathsWillBeCleared
|
||||
: context.l10n.appSettings_pathsWillNotBeCleared),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_pathsWillBeCleared
|
||||
: context.l10n.appSettings_pathsWillNotBeCleared,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
@ -241,9 +286,11 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
settingsService.setAutoRouteRotationEnabled(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_autoRouteRotationEnabled
|
||||
: context.l10n.appSettings_autoRouteRotationDisabled),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_autoRouteRotationEnabled
|
||||
: context.l10n.appSettings_autoRouteRotationDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
@ -254,7 +301,10 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildMapSettingsCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -302,7 +352,9 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
subtitle: Text(
|
||||
settingsService.settings.mapTimeFilterHours == 0
|
||||
? context.l10n.appSettings_timeFilterShowAll
|
||||
: context.l10n.appSettings_timeFilterShowLast(settingsService.settings.mapTimeFilterHours.toInt()),
|
||||
: context.l10n.appSettings_timeFilterShowLast(
|
||||
settingsService.settings.mapTimeFilterHours.toInt(),
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showTimeFilterDialog(context, settingsService),
|
||||
|
|
@ -339,8 +391,9 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
) {
|
||||
final deviceId = connector.deviceId;
|
||||
final isConnected = connector.isConnected && deviceId != null;
|
||||
final selection =
|
||||
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
|
||||
final selection = isConnected
|
||||
? settingsService.batteryChemistryForDevice(deviceId)
|
||||
: 'nmc';
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
|
|
@ -358,7 +411,9 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
title: Text(context.l10n.appSettings_batteryChemistry),
|
||||
subtitle: Text(
|
||||
isConnected
|
||||
? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName)
|
||||
? context.l10n.appSettings_batteryChemistryPerDevice(
|
||||
connector.deviceDisplayName,
|
||||
)
|
||||
: context.l10n.appSettings_batteryChemistryConnectFirst,
|
||||
),
|
||||
trailing: DropdownButton<String>(
|
||||
|
|
@ -366,7 +421,10 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
onChanged: isConnected
|
||||
? (value) {
|
||||
if (value != null) {
|
||||
settingsService.setBatteryChemistryForDevice(deviceId, value);
|
||||
settingsService.setBatteryChemistryForDevice(
|
||||
deviceId,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
|
|
@ -391,7 +449,10 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showThemeModeDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
|
@ -480,7 +541,10 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showLanguageDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
|
@ -573,7 +637,10 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showTimeFilterDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
|
@ -593,33 +660,23 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_allTime),
|
||||
leading: Radio<double>(
|
||||
value: 0,
|
||||
),
|
||||
leading: Radio<double>(value: 0),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_lastHour),
|
||||
leading: Radio<double>(
|
||||
value: 1,
|
||||
),
|
||||
leading: Radio<double>(value: 1),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_last6Hours),
|
||||
leading: Radio<double>(
|
||||
value: 6,
|
||||
),
|
||||
leading: Radio<double>(value: 6),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_last24Hours),
|
||||
leading: Radio<double>(
|
||||
value: 24,
|
||||
),
|
||||
leading: Radio<double>(value: 24),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_lastWeek),
|
||||
leading: Radio<double>(
|
||||
value: 168,
|
||||
),
|
||||
leading: Radio<double>(value: 168),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -634,7 +691,10 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildDebugCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -656,9 +716,11 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_appDebugLoggingEnabled
|
||||
: context.l10n.appSettings_appDebugLoggingDisabled),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_appDebugLoggingEnabled
|
||||
: context.l10n.appSettings_appDebugLoggingDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||
final entries = logService.entries.reversed.toList();
|
||||
final rawEntries = logService.rawLogRxEntries.reversed.toList();
|
||||
final showingFrames = _view == _BleLogView.frames;
|
||||
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
|
||||
final hasEntries = showingFrames
|
||||
? entries.isNotEmpty
|
||||
: rawEntries.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.debugLog_bleTitle),
|
||||
|
|
@ -36,15 +38,23 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||
? () async {
|
||||
final text = showingFrames
|
||||
? entries
|
||||
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
|
||||
.join('\n')
|
||||
.map(
|
||||
(entry) =>
|
||||
'${entry.description}\n${entry.hexPreview}\n',
|
||||
)
|
||||
.join('\n')
|
||||
: rawEntries
|
||||
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
|
||||
.join('\n');
|
||||
.map(
|
||||
(entry) =>
|
||||
'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n',
|
||||
)
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.debugLog_bleCopied)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.debugLog_bleCopied),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
|
|
@ -68,8 +78,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: SegmentedButton<_BleLogView>(
|
||||
segments: [
|
||||
ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)),
|
||||
ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)),
|
||||
ButtonSegment(
|
||||
value: _BleLogView.frames,
|
||||
label: Text(context.l10n.debugLog_frames),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _BleLogView.rawLogRx,
|
||||
label: Text(context.l10n.debugLog_rawLogRx),
|
||||
),
|
||||
],
|
||||
selected: {_view},
|
||||
onSelectionChanged: (selection) {
|
||||
|
|
@ -81,7 +97,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||
Expanded(
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: showingFrames ? entries.length : rawEntries.length,
|
||||
itemCount: showingFrames
|
||||
? entries.length
|
||||
: rawEntries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
if (showingFrames) {
|
||||
|
|
@ -94,7 +112,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||
subtitle: Text('${entry.hexPreview}\n$time'),
|
||||
isThreeLine: true,
|
||||
leading: Icon(
|
||||
entry.outgoing ? Icons.upload : Icons.download,
|
||||
entry.outgoing
|
||||
? Icons.upload
|
||||
: Icons.download,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
|
|
@ -131,9 +151,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(info.title),
|
||||
content: SingleChildScrollView(
|
||||
child: SelectableText(info.rawHex),
|
||||
),
|
||||
content: SingleChildScrollView(child: SelectableText(info.rawHex)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
@ -195,11 +213,18 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||
}
|
||||
final payload = raw.sublist(index);
|
||||
|
||||
final title = 'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final title =
|
||||
'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final summary = _decodePayloadSummary(payloadType, payload);
|
||||
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
|
||||
final pathSummary = pathLen > 0
|
||||
? 'Path=${_bytesToHex(pathBytes)}'
|
||||
: 'Path=none';
|
||||
final detail = '$summary • $pathSummary • len=${raw.length}';
|
||||
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw));
|
||||
return _RawPacketInfo(
|
||||
title: title,
|
||||
summary: detail,
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
|
||||
String _decodePayloadSummary(int payloadType, Uint8List payload) {
|
||||
|
|
@ -245,7 +270,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||
return 'ADVERT (short)';
|
||||
}
|
||||
var offset = 0;
|
||||
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false);
|
||||
final pubKey = _bytesToHex(
|
||||
payload.sublist(offset, offset + 32),
|
||||
spaced: false,
|
||||
);
|
||||
offset += 32;
|
||||
final timestamp = readUint32LE(payload, offset);
|
||||
offset += 4;
|
||||
|
|
|
|||
|
|
@ -27,10 +27,7 @@ import 'map_screen.dart';
|
|||
class ChannelChatScreen extends StatefulWidget {
|
||||
final Channel channel;
|
||||
|
||||
const ChannelChatScreen({
|
||||
super.key,
|
||||
required this.channel,
|
||||
});
|
||||
const ChannelChatScreen({super.key, required this.channel});
|
||||
|
||||
@override
|
||||
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
||||
|
|
@ -135,15 +132,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
children: [
|
||||
Text(
|
||||
widget.channel.name.isEmpty
|
||||
? context.l10n.channels_channelIndex(widget.channel.index)
|
||||
? context.l10n.channels_channelIndex(
|
||||
widget.channel.index,
|
||||
)
|
||||
: widget.channel.name,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final unreadCount =
|
||||
connector.getUnreadCountForChannelIndex(widget.channel.index);
|
||||
final privacy = widget.channel.isPublicChannel ? context.l10n.channels_public : context.l10n.channels_private;
|
||||
final unreadCount = connector
|
||||
.getUnreadCountForChannelIndex(widget.channel.index);
|
||||
final privacy = widget.channel.isPublicChannel
|
||||
? context.l10n.channels_public
|
||||
: context.l10n.channels_private;
|
||||
return Text(
|
||||
'$privacy • ${context.l10n.chat_unread(unreadCount)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
@ -202,7 +203,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
|
||||
// Reverse messages so newest appear at bottom with reverse: true
|
||||
final reversedMessages = messages.reversed.toList();
|
||||
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
|
||||
final itemCount =
|
||||
reversedMessages.length + (_isLoadingOlder ? 1 : 0);
|
||||
|
||||
// Auto-scroll to bottom if user is already at bottom
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
|
@ -225,7 +227,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -241,9 +245,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
);
|
||||
},
|
||||
),
|
||||
JumpToBottomButton(
|
||||
scrollController: _scrollController,
|
||||
),
|
||||
JumpToBottomButton(scrollController: _scrollController),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
@ -262,15 +264,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
final poi = _parsePoiMessage(message.text);
|
||||
final displayPath = message.pathBytes.isNotEmpty
|
||||
? message.pathBytes
|
||||
: (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0));
|
||||
: (message.pathVariants.isNotEmpty
|
||||
? message.pathVariants.first
|
||||
: Uint8List(0));
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
crossAxisAlignment: isOutgoing
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
|
|
@ -282,128 +290,160 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
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,
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
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 (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],
|
||||
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 (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 (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 (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
message.status == ChannelMessageStatus.sent
|
||||
? Icons.check
|
||||
: message.status == ChannelMessageStatus.pending
|
||||
)
|
||||
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],
|
||||
),
|
||||
),
|
||||
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],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
size: 14,
|
||||
color:
|
||||
message.status ==
|
||||
ChannelMessageStatus.failed
|
||||
? Colors.red
|
||||
: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
|
|
@ -444,7 +484,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
children: [
|
||||
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.chat_location, style: TextStyle(fontSize: 12, color: previewTextColor)),
|
||||
Text(
|
||||
context.l10n.chat_location,
|
||||
style: TextStyle(fontSize: 12, color: previewTextColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
|
|
@ -468,10 +511,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
left: BorderSide(color: colorScheme.primary, width: 3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
|
|
@ -509,17 +549,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
if (count > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
|
|
@ -546,7 +585,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
|
||||
_PoiInfo? _parsePoiMessage(String text) {
|
||||
final trimmed = text.trim();
|
||||
final match = RegExp(r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|').firstMatch(trimmed);
|
||||
final match = RegExp(
|
||||
r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|',
|
||||
).firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
final lat = double.tryParse(match.group(1) ?? '');
|
||||
final lon = double.tryParse(match.group(2) ?? '');
|
||||
|
|
@ -557,10 +598,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
|
||||
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textColor =
|
||||
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
|
||||
final textColor = isOutgoing
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface;
|
||||
final metaColor = textColor.withValues(alpha: 0.7);
|
||||
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue;
|
||||
final channelColor = widget.channel.isPublicChannel
|
||||
? Colors.orange
|
||||
: Colors.blue;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
|
|
@ -588,18 +632,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
children: [
|
||||
Text(
|
||||
context.l10n.chat_poiShared,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (poi.label.isNotEmpty)
|
||||
Text(
|
||||
poi.label,
|
||||
style: TextStyle(
|
||||
color: metaColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: TextStyle(color: metaColor, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -676,10 +714,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
|
|
@ -708,7 +743,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -746,73 +783,76 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
onPressed: () => _showGifPicker(context),
|
||||
tooltip: context.l10n.chat_sendGif,
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
builder: (context, value, child) {
|
||||
final gifId = _parseGifId(value.text);
|
||||
if (gifId != null) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor:
|
||||
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
maxSize: 160,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
onPressed: () => _showGifPicker(context),
|
||||
tooltip: context.l10n.chat_sendGif,
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
builder: (context, value, child) {
|
||||
final gifId = _parseGifId(value.text);
|
||||
if (gifId != null) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: GifMessage(
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
maxSize: 160,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -932,24 +972,28 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
||||
if (emojiIndex == null) return; // Unknown emoji, skip
|
||||
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
final hash = ReactionHelper.computeReactionHash(timestampSecs, message.senderName, message.text);
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
timestampSecs,
|
||||
message.senderName,
|
||||
message.text,
|
||||
);
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
connector.sendChannelMessage(widget.channel, reactionText);
|
||||
}
|
||||
|
||||
void _copyMessageText(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_messageCopied)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
|
||||
}
|
||||
|
||||
Future<void> _deleteMessage(ChannelMessage message) async {
|
||||
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
|
||||
}
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
|
|
@ -964,9 +1008,5 @@ class _PoiInfo {
|
|||
final double lon;
|
||||
final String label;
|
||||
|
||||
const _PoiInfo({
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
required this.label,
|
||||
});
|
||||
const _PoiInfo({required this.lat, required this.lon, required this.label});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,17 +17,17 @@ import '../models/contact.dart';
|
|||
class ChannelMessagePathScreen extends StatelessWidget {
|
||||
final ChannelMessage message;
|
||||
|
||||
const ChannelMessagePathScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
});
|
||||
const ChannelMessagePathScreen({super.key, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final l10n = context.l10n;
|
||||
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
|
||||
final primaryPath = _selectPrimaryPath(
|
||||
message.pathBytes,
|
||||
message.pathVariants,
|
||||
);
|
||||
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
|
|
@ -88,10 +88,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(
|
||||
BuildContext context, {
|
||||
String? observedLabel,
|
||||
}) {
|
||||
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
|
|
@ -105,21 +102,28 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
|
||||
_buildDetailRow(l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n)),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_timeLabel,
|
||||
_formatTime(message.timestamp, l10n),
|
||||
),
|
||||
if (message.repeatCount > 0)
|
||||
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()),
|
||||
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)),
|
||||
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_repeatsLabel,
|
||||
message.repeatCount.toString(),
|
||||
),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_pathLabelTitle,
|
||||
_formatPathLabel(message.pathLength, l10n),
|
||||
),
|
||||
if (observedLabel != null)
|
||||
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathVariants(
|
||||
BuildContext context,
|
||||
List<Uint8List> variants,
|
||||
) {
|
||||
Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
|
||||
final l10n = context.l10n;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -163,7 +167,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||
subtitle: Text(
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: l10n.channelPath_noLocationData,
|
||||
),
|
||||
),
|
||||
|
|
@ -239,7 +243,6 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ChannelMessagePathMapScreen extends StatefulWidget {
|
||||
|
|
@ -257,7 +260,8 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
|
|||
_ChannelMessagePathMapScreenState();
|
||||
}
|
||||
|
||||
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> {
|
||||
class _ChannelMessagePathMapScreenState
|
||||
extends State<ChannelMessagePathMapScreen> {
|
||||
Uint8List? _selectedPath;
|
||||
|
||||
@override
|
||||
|
|
@ -270,8 +274,10 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
|||
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.message != widget.message ||
|
||||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0))) {
|
||||
!_pathsEqual(
|
||||
oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0),
|
||||
)) {
|
||||
_selectedPath = widget.initialPath;
|
||||
}
|
||||
}
|
||||
|
|
@ -281,17 +287,25 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
|||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final primaryPath =
|
||||
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
|
||||
final observedPaths =
|
||||
_buildObservedPaths(primaryPath, widget.message.pathVariants);
|
||||
final primaryPath = _selectPrimaryPath(
|
||||
widget.message.pathBytes,
|
||||
widget.message.pathVariants,
|
||||
);
|
||||
final observedPaths = _buildObservedPaths(
|
||||
primaryPath,
|
||||
widget.message.pathVariants,
|
||||
);
|
||||
final selectedPath = _resolveSelectedPath(
|
||||
_selectedPath,
|
||||
observedPaths,
|
||||
primaryPath,
|
||||
);
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
|
||||
final hops = _buildPathHops(
|
||||
selectedPath,
|
||||
connector.contacts,
|
||||
context.l10n,
|
||||
);
|
||||
final points = hops
|
||||
.where((hop) => hop.hasLocation)
|
||||
.map((hop) => hop.position!)
|
||||
|
|
@ -306,16 +320,17 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
|||
]
|
||||
: <Polyline>[];
|
||||
|
||||
final initialCenter =
|
||||
points.isNotEmpty ? points.first : const LatLng(0, 0);
|
||||
final initialCenter = points.isNotEmpty
|
||||
? points.first
|
||||
: const LatLng(0, 0);
|
||||
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
|
||||
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
|
||||
final bounds = points.length > 1
|
||||
? LatLngBounds.fromPoints(points)
|
||||
: null;
|
||||
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.channelPath_mapTitle),
|
||||
),
|
||||
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
|
|
@ -343,30 +358,28 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
|||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
|
||||
MarkerLayer(
|
||||
markers: _buildHopMarkers(hops),
|
||||
),
|
||||
if (polylines.isNotEmpty)
|
||||
PolylineLayer(polylines: polylines),
|
||||
MarkerLayer(markers: _buildHopMarkers(hops)),
|
||||
],
|
||||
),
|
||||
if (observedPaths.length > 1)
|
||||
_buildPathSelector(
|
||||
context,
|
||||
observedPaths,
|
||||
selectedIndex,
|
||||
(index) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildPathSelector(context, observedPaths, selectedIndex, (
|
||||
index,
|
||||
) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
}),
|
||||
if (points.isEmpty)
|
||||
Center(
|
||||
child: Card(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(context.l10n.channelPath_noRepeaterLocations),
|
||||
child: Text(
|
||||
context.l10n.channelPath_noRepeaterLocations,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -525,7 +538,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
|||
subtitle: Text(
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: l10n.channelPath_noLocationData,
|
||||
),
|
||||
);
|
||||
|
|
@ -567,10 +580,7 @@ class _ObservedPath {
|
|||
final Uint8List pathBytes;
|
||||
final bool isPrimary;
|
||||
|
||||
const _ObservedPath({
|
||||
required this.pathBytes,
|
||||
required this.isPrimary,
|
||||
});
|
||||
const _ObservedPath({required this.pathBytes, required this.isPrimary});
|
||||
}
|
||||
|
||||
List<_PathHop> _buildPathHops(
|
||||
|
|
@ -597,10 +607,12 @@ List<_PathHop> _buildPathHops(
|
|||
|
||||
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
||||
final matches = contacts
|
||||
.where((contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix)
|
||||
.where(
|
||||
(contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix,
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -154,7 +154,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -951,7 +953,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||
dialogContext.l10n.community_communityHashtag,
|
||||
),
|
||||
subtitle: Text(
|
||||
dialogContext.l10n.community_communityHashtagDesc,
|
||||
dialogContext
|
||||
.l10n
|
||||
.community_communityHashtagDesc,
|
||||
),
|
||||
dense: true,
|
||||
),
|
||||
|
|
@ -1047,7 +1051,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||
hashtag = hashtag.substring(1);
|
||||
}
|
||||
final String channelName;
|
||||
|
||||
|
||||
final Uint8List psk;
|
||||
if (isRegularHashtag) {
|
||||
channelName = '#$hashtag';
|
||||
|
|
@ -1069,8 +1073,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||
);
|
||||
return;
|
||||
}
|
||||
channelName = '${selectedCommunity!.name} #$hashtag';
|
||||
psk = selectedCommunity!.deriveCommunityHashtagPsk(hashtag);
|
||||
channelName =
|
||||
'${selectedCommunity!.name} #$hashtag';
|
||||
psk = selectedCommunity!
|
||||
.deriveCommunityHashtagPsk(hashtag);
|
||||
// Track in community's hashtag list
|
||||
await _communityStore.addHashtagChannel(
|
||||
selectedCommunity!.id,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
|
||||
context.read<MeshCoreConnector>().setActiveContact(
|
||||
widget.contact.publicKeyHex,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -91,12 +93,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
title: Consumer2<PathHistoryService, MeshCoreConnector>(
|
||||
builder: (context, pathService, connector, _) {
|
||||
final contact = _resolveContact(connector);
|
||||
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
|
||||
final unreadCount = connector.getUnreadCountForContactKey(
|
||||
widget.contact.publicKeyHex,
|
||||
);
|
||||
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
||||
final pathLabel = _currentPathLabel(contact);
|
||||
|
||||
// Show path details if we have path data (from device or override)
|
||||
final hasPathData = contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
||||
final hasPathData =
|
||||
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
||||
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
||||
|
||||
return Column(
|
||||
|
|
@ -106,7 +111,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
Text(contact.name),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null,
|
||||
onTap: hasPathData
|
||||
? () => _showFullPathDialog(context, effectivePath)
|
||||
: null,
|
||||
child: Text(
|
||||
'$pathLabel • $unreadLabel',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
@ -144,12 +151,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.chat_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -159,12 +174,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.chat_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -196,9 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
messages.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildMessageList(messages, connector),
|
||||
JumpToBottomButton(
|
||||
scrollController: _scrollController,
|
||||
),
|
||||
JumpToBottomButton(scrollController: _scrollController),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -231,7 +252,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageList(List<Message> messages, MeshCoreConnector connector) {
|
||||
Widget _buildMessageList(
|
||||
List<Message> messages,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
// Reverse messages so newest appear at bottom with reverse: true
|
||||
final reversedMessages = messages.reversed.toList();
|
||||
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
|
||||
|
|
@ -267,14 +291,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
if (widget.contact.type == advTypeRoom) {
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
connector,
|
||||
message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey,
|
||||
message.fourByteRoomContactKey.isEmpty
|
||||
? Uint8List.fromList([0, 0, 0, 0])
|
||||
: message.fourByteRoomContactKey,
|
||||
);
|
||||
fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
|
||||
fourByteHex = message.fourByteRoomContactKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
senderName: widget.contact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : contact.name,
|
||||
senderName: widget.contact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
: contact.name,
|
||||
isRoomServer: widget.contact.type == advTypeRoom,
|
||||
onTap: () => _openMessagePath(message, contact),
|
||||
onLongPress: () => _showMessageActions(message, contact),
|
||||
|
|
@ -290,9 +321,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
|
|
@ -314,10 +343,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor:
|
||||
colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
maxSize: 160,
|
||||
),
|
||||
),
|
||||
|
|
@ -341,7 +372,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(connector),
|
||||
|
|
@ -390,14 +424,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
return;
|
||||
}
|
||||
|
||||
connector.sendMessage(
|
||||
widget.contact,
|
||||
text,
|
||||
);
|
||||
connector.sendMessage(widget.contact, text);
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
|
||||
void _showPathHistory(BuildContext context) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
|
|
@ -422,13 +452,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
if (paths.isNotEmpty) ...[
|
||||
Text(
|
||||
context.l10n.chat_recentAckPaths,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (paths.length >= 100) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
@ -447,7 +483,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
|
||||
backgroundColor: path.wasFloodDiscovery
|
||||
? Colors.blue
|
||||
: Colors.green,
|
||||
child: Text(
|
||||
'${path.hopCount}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
|
|
@ -475,23 +513,36 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
},
|
||||
),
|
||||
path.wasFloodDiscovery
|
||||
? const Icon(Icons.waves, size: 16, color: Colors.grey)
|
||||
: const Icon(Icons.route, size: 16, color: Colors.grey),
|
||||
? const Icon(
|
||||
Icons.waves,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.route,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
onLongPress: () => _showFullPathDialog(context, path.pathBytes),
|
||||
onLongPress: () =>
|
||||
_showFullPathDialog(context, path.pathBytes),
|
||||
onTap: () async {
|
||||
if (path.pathBytes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.chat_pathDetailsNotAvailable),
|
||||
content: Text(
|
||||
context.l10n.chat_pathDetailsNotAvailable,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final pathBytes = Uint8List.fromList(path.pathBytes);
|
||||
final pathBytes = Uint8List.fromList(
|
||||
path.pathBytes,
|
||||
);
|
||||
final pathLength = path.pathBytes.length;
|
||||
|
||||
// Set the path override to persist user's choice
|
||||
|
|
@ -521,7 +572,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chat_pathActions,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
|
|
@ -531,8 +585,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
backgroundColor: Colors.purple,
|
||||
child: Icon(Icons.edit_road, size: 16),
|
||||
),
|
||||
title: Text(context.l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(context.l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
title: Text(
|
||||
context.l10n.chat_setCustomPath,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.chat_setCustomPathSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showCustomPathDialog(context);
|
||||
|
|
@ -545,8 +605,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
backgroundColor: Colors.orange,
|
||||
child: Icon(Icons.clear_all, size: 16),
|
||||
),
|
||||
title: Text(context.l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(context.l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
title: Text(
|
||||
context.l10n.chat_clearPath,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.chat_clearPathSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.clearContactPath(widget.contact);
|
||||
if (!context.mounted) return;
|
||||
|
|
@ -566,10 +632,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.waves, size: 16),
|
||||
),
|
||||
title: Text(context.l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(context.l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
title: Text(
|
||||
context.l10n.chat_forceFloodMode,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.chat_floodModeSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.setPathOverride(widget.contact, pathLen: -1);
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
pathLen: -1,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
@ -598,7 +673,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
String _formatRelativeTime(DateTime time) {
|
||||
final diff = DateTime.now().difference(time);
|
||||
if (diff.inSeconds < 60) return context.l10n.time_justNow;
|
||||
if (diff.inMinutes < 60) return context.l10n.time_minutesAgo(diff.inMinutes);
|
||||
if (diff.inMinutes < 60)
|
||||
return context.l10n.time_minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours);
|
||||
return context.l10n.time_daysAgo(diff.inDays);
|
||||
}
|
||||
|
|
@ -640,7 +716,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Contact _resolveContactFrom4Bytes(MeshCoreConnector connector, Uint8List key4Bytes) {
|
||||
Contact _resolveContactFrom4Bytes(
|
||||
MeshCoreConnector connector,
|
||||
Uint8List key4Bytes,
|
||||
) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
|
||||
orElse: () => widget.contact,
|
||||
|
|
@ -674,12 +753,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
|
||||
final status = !connector.isConnected
|
||||
? context.l10n.chat_pathSavedLocally
|
||||
: (verified ? context.l10n.chat_pathDeviceConfirmed : context.l10n.chat_pathDeviceNotConfirmed);
|
||||
: (verified
|
||||
? context.l10n.chat_pathDeviceConfirmed
|
||||
: context.l10n.chat_pathDeviceNotConfirmed);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.chat_pathSetHops(hopCount, status),
|
||||
),
|
||||
content: Text(context.l10n.chat_pathSetHops(hopCount, status)),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
|
|
@ -694,7 +773,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
builder: (context) => Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final contact = _resolveContact(connector);
|
||||
final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
|
||||
final smazEnabled = connector.isContactSmazEnabled(
|
||||
contact.publicKeyHex,
|
||||
);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(contact.name),
|
||||
|
|
@ -710,7 +791,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
context.l10n.chat_location,
|
||||
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
||||
),
|
||||
_buildInfoRow(context.l10n.chat_publicKey, '${contact.publicKeyHex.substring(0, 16)}...'),
|
||||
_buildInfoRow(
|
||||
context.l10n.chat_publicKey,
|
||||
'${contact.publicKeyHex.substring(0, 16)}...',
|
||||
),
|
||||
const Divider(),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
|
@ -718,7 +802,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
|
||||
value: smazEnabled,
|
||||
onChanged: (value) {
|
||||
connector.setContactSmazEnabled(contact.publicKeyHex, value);
|
||||
connector.setContactSmazEnabled(
|
||||
contact.publicKeyHex,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
@ -765,7 +852,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
final currentContact = _resolveContact(connector);
|
||||
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
|
||||
if (currentContact.pathLength > 0 &&
|
||||
currentContact.path.isEmpty &&
|
||||
connector.isConnected) {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
||||
|
|
@ -786,19 +875,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
onRefresh: connector.isConnected ? connector.getContacts : null,
|
||||
);
|
||||
|
||||
appLogger.info('PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted', tag: 'ChatScreen');
|
||||
appLogger.info(
|
||||
'PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'ChatScreen');
|
||||
appLogger.info(
|
||||
'PathSelectionDialog was cancelled or returned null',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
appLogger.warn('Widget not mounted after dialog, cannot set path', tag: 'ChatScreen');
|
||||
appLogger.warn(
|
||||
'Widget not mounted after dialog, cannot set path',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen');
|
||||
appLogger.info(
|
||||
'Calling setPathOverride for ${widget.contact.name}',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
pathLen: result.length,
|
||||
|
|
@ -810,7 +911,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
await _notifyPathSet(connector, widget.contact, result, result.length);
|
||||
}
|
||||
|
||||
|
||||
void _openMessagePath(Message message, Contact contact) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final fourByteHex = message.fourByteRoomContactKey
|
||||
|
|
@ -877,8 +977,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
await _deleteMessage(message);
|
||||
},
|
||||
),
|
||||
if (message.isOutgoing &&
|
||||
message.status == MessageStatus.failed)
|
||||
if (message.isOutgoing && message.status == MessageStatus.failed)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: Text(context.l10n.common_retry),
|
||||
|
|
@ -909,29 +1008,26 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
|
||||
void _copyMessageText(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_messageCopied)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
|
||||
}
|
||||
|
||||
Future<void> _deleteMessage(Message message) async {
|
||||
await context.read<MeshCoreConnector>().deleteMessage(message);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
|
||||
}
|
||||
|
||||
void _retryMessage(Message message) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
// Retry using the contact's current path override setting
|
||||
connector.sendMessage(
|
||||
widget.contact,
|
||||
message.text,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_retryingMessage)),
|
||||
);
|
||||
connector.sendMessage(widget.contact, message.text);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
||||
}
|
||||
|
||||
void _showEmojiPicker(Message message, Contact senderContact) {
|
||||
|
|
@ -951,11 +1047,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
||||
if (emojiIndex == null) return; // Unknown emoji, skip
|
||||
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
|
||||
// For room servers, include sender name (like channels) since multiple users
|
||||
// For 1:1 chats, sender is implicit (null)
|
||||
final senderName = widget.contact.type == advTypeRoom ? senderContact.name : null;
|
||||
final hash = ReactionHelper.computeReactionHash(timestampSecs, senderName, message.text);
|
||||
final senderName = widget.contact.type == advTypeRoom
|
||||
? senderContact.name
|
||||
: null;
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
timestampSecs,
|
||||
senderName,
|
||||
message.text,
|
||||
);
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
connector.sendMessage(widget.contact, reactionText);
|
||||
}
|
||||
|
|
@ -985,7 +1087,9 @@ class _MessageBubble extends StatelessWidget {
|
|||
final isFailed = message.status == MessageStatus.failed;
|
||||
final bubbleColor = isFailed
|
||||
? colorScheme.errorContainer
|
||||
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest);
|
||||
: (isOutgoing
|
||||
? colorScheme.primary
|
||||
: colorScheme.surfaceContainerHighest);
|
||||
final textColor = isFailed
|
||||
? colorScheme.onErrorContainer
|
||||
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
|
||||
|
|
@ -997,13 +1101,17 @@ class _MessageBubble extends StatelessWidget {
|
|||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
crossAxisAlignment: isOutgoing
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Row(
|
||||
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
|
|
@ -1012,133 +1120,154 @@ class _MessageBubble extends StatelessWidget {
|
|||
],
|
||||
Flexible(
|
||||
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: bubbleColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
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(
|
||||
senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, textColor, metaColor)
|
||||
else if (gifId != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: textColor.withValues(alpha: 0.7),
|
||||
),
|
||||
)
|
||||
else
|
||||
Linkify(
|
||||
text: messageText,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
),
|
||||
linkStyle: const TextStyle(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) => LinkHandler.handleLinkTap(context, link.url),
|
||||
),
|
||||
if (isOutgoing && message.retryCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
context.l10n.chat_retryCount(message.retryCount, 4),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
|
||||
: EdgeInsets.zero,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(metaColor),
|
||||
],
|
||||
if (message.tripTimeMs != null &&
|
||||
message.status == MessageStatus.delivered) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.speed,
|
||||
size: 10,
|
||||
color: isOutgoing ? metaColor : Colors.green[700],
|
||||
),
|
||||
Text(
|
||||
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
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(
|
||||
senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isOutgoing ? metaColor : Colors.green[700],
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, textColor, metaColor)
|
||||
else if (gifId != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: GifMessage(
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: textColor.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Linkify(
|
||||
text: messageText,
|
||||
style: TextStyle(color: textColor),
|
||||
linkStyle: const TextStyle(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) =>
|
||||
LinkHandler.handleLinkTap(context, link.url),
|
||||
),
|
||||
if (isOutgoing && message.retryCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
context.l10n.chat_retryCount(
|
||||
message.retryCount,
|
||||
4,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 4,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(metaColor),
|
||||
],
|
||||
if (message.tripTimeMs != null &&
|
||||
message.status ==
|
||||
MessageStatus.delivered) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.speed,
|
||||
size: 10,
|
||||
color: isOutgoing
|
||||
? metaColor
|
||||
: Colors.green[700],
|
||||
),
|
||||
Text(
|
||||
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isOutgoing
|
||||
? metaColor
|
||||
: Colors.green[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
||||
child: _buildReactionsDisplay(context, message, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
||||
child: _buildReactionsDisplay(context, message, colorScheme),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1151,8 +1280,9 @@ class _MessageBubble extends StatelessWidget {
|
|||
|
||||
_PoiInfo? _parsePoiMessage(String text) {
|
||||
final trimmed = text.trim();
|
||||
final match = RegExp(r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$')
|
||||
.firstMatch(trimmed);
|
||||
final match = RegExp(
|
||||
r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$',
|
||||
).firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
final lat = double.tryParse(match.group(1) ?? '');
|
||||
final lon = double.tryParse(match.group(2) ?? '');
|
||||
|
|
@ -1193,18 +1323,12 @@ class _MessageBubble extends StatelessWidget {
|
|||
children: [
|
||||
Text(
|
||||
context.l10n.chat_poiShared,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (poi.label.isNotEmpty)
|
||||
Text(
|
||||
poi.label,
|
||||
style: TextStyle(
|
||||
color: metaColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: TextStyle(color: metaColor, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1213,7 +1337,11 @@ class _MessageBubble extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildReactionsDisplay(BuildContext context, Message message, ColorScheme colorScheme) {
|
||||
Widget _buildReactionsDisplay(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
|
|
@ -1234,10 +1362,7 @@ class _MessageBubble extends StatelessWidget {
|
|||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
if (count > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
|
|
@ -1321,11 +1446,7 @@ class _MessageBubble extends StatelessWidget {
|
|||
break;
|
||||
}
|
||||
|
||||
return Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: color,
|
||||
);
|
||||
return Icon(icon, size: 12, color: color);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
|
|
@ -1340,9 +1461,5 @@ class _PoiInfo {
|
|||
final double lon;
|
||||
final String label;
|
||||
|
||||
const _PoiInfo({
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
required this.label,
|
||||
});
|
||||
const _PoiInfo({required this.lat, required this.lon, required this.label});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,16 +29,9 @@ import 'map_screen.dart';
|
|||
import 'repeater_hub_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
enum RoomLoginDestination {
|
||||
chat,
|
||||
management,
|
||||
}
|
||||
enum RoomLoginDestination { chat, management }
|
||||
|
||||
enum ContactOperationType {
|
||||
import,
|
||||
export,
|
||||
zeroHopShare,
|
||||
}
|
||||
enum ContactOperationType { import, export, zeroHopShare }
|
||||
|
||||
class ContactsScreen extends StatefulWidget {
|
||||
final bool hideBackButton;
|
||||
|
|
@ -105,7 +98,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
if (advertPacket.length < 98) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_invalidAdvertFormat),
|
||||
),
|
||||
);
|
||||
}
|
||||
_pendingOperations.remove(ContactOperationType.export);
|
||||
|
|
@ -115,23 +110,25 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
||||
}
|
||||
|
||||
if(code == respCodeOk) {
|
||||
if (code == respCodeOk) {
|
||||
// Show a snackbar indicating success
|
||||
if(!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.import)){
|
||||
if (_pendingOperations.contains(ContactOperationType.import)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactImported)),
|
||||
);
|
||||
}
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
|
||||
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_zeroHopContactAdvertSent)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.export)) {
|
||||
if (_pendingOperations.contains(ContactOperationType.export)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
|
||||
);
|
||||
|
|
@ -140,30 +137,33 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
_pendingOperations.clear();
|
||||
}
|
||||
|
||||
if(code == respCodeErr) {
|
||||
if (code == respCodeErr) {
|
||||
// Show a snackbar indicating failure
|
||||
if(!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.import)){
|
||||
if (_pendingOperations.contains(ContactOperationType.import)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactImportFailed)),
|
||||
);
|
||||
}
|
||||
|
||||
if(_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
|
||||
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_zeroHopContactAdvertFailed)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
|
||||
),
|
||||
);
|
||||
}
|
||||
if(_pendingOperations.contains(ContactOperationType.export)) {
|
||||
if (_pendingOperations.contains(ContactOperationType.export)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopyFailed)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_pendingOperations.clear();
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final clipboardData = await Clipboard.getData('text/plain');
|
||||
if (clipboardData == null || clipboardData.text == null) {
|
||||
if(mounted) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)),
|
||||
);
|
||||
|
|
@ -194,7 +194,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
}
|
||||
final text = clipboardData.text!.trim();
|
||||
if (!text.startsWith('meshcore://')) {
|
||||
if(mounted) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
|
||||
);
|
||||
|
|
@ -207,7 +207,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
_pendingOperations.add(ContactOperationType.import);
|
||||
await connector.sendFrame(importContactFrame);
|
||||
} catch (e) {
|
||||
if(mounted) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
|
||||
);
|
||||
|
|
@ -234,59 +234,64 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
PopupMenuButton(itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.connect_without_contact),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_zeroHopAdvert),
|
||||
],
|
||||
),
|
||||
onTap: () => {
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.connect_without_contact),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_zeroHopAdvert),
|
||||
],
|
||||
),
|
||||
onTap: () => {
|
||||
connector.sendSelfAdvert(flood: false),
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))),
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cell_tower),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_floodAdvert),
|
||||
],
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.settings_advertisementSent),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
onTap: () => {
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cell_tower),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_floodAdvert),
|
||||
],
|
||||
),
|
||||
onTap: () => {
|
||||
connector.sendSelfAdvert(flood: true),
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))),
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.copy),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_copyAdvertToClipboard),
|
||||
],
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.settings_advertisementSent),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
onTap: () => _contactExport(Uint8List.fromList([])),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.paste),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_addContactFromClipboard),
|
||||
],
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.copy),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_copyAdvertToClipboard),
|
||||
],
|
||||
),
|
||||
onTap: () => _contactExport(Uint8List.fromList([])),
|
||||
),
|
||||
onTap: () => _contactImport(),
|
||||
),
|
||||
],
|
||||
icon: const Icon(Icons.connect_without_contact),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.paste),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.contacts_addContactFromClipboard),
|
||||
],
|
||||
),
|
||||
onTap: () => _contactImport(),
|
||||
),
|
||||
],
|
||||
icon: const Icon(Icons.connect_without_contact),
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
|
|
@ -310,7 +315,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -704,7 +711,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => destination == RoomLoginDestination.management
|
||||
builder: (context) =>
|
||||
destination == RoomLoginDestination.management
|
||||
? RepeaterHubScreen(repeater: room, password: password)
|
||||
: ChatScreen(contact: room),
|
||||
),
|
||||
|
|
@ -970,15 +978,22 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
if (isRepeater) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping),
|
||||
title: contact.pathLength > 0
|
||||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0 ? context.l10n.contacts_repeaterPathTrace : context.l10n.contacts_repeaterPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
});
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0
|
||||
? context.l10n.contacts_repeaterPathTrace
|
||||
: context.l10n.contacts_repeaterPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cell_tower, color: Colors.orange),
|
||||
|
|
@ -987,19 +1002,26 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
Navigator.pop(sheetContext);
|
||||
_showRepeaterLogin(context, contact);
|
||||
},
|
||||
)
|
||||
]else if (isRoom) ...[
|
||||
),
|
||||
] else if (isRoom) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping),
|
||||
title: contact.pathLength > 0
|
||||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0 ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
});
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0
|
||||
? context.l10n.contacts_roomPathTrace
|
||||
: context.l10n.contacts_roomPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room, color: Colors.blue),
|
||||
|
|
@ -1010,27 +1032,39 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room_preferences, color: Colors.orange),
|
||||
leading: const Icon(
|
||||
Icons.room_preferences,
|
||||
color: Colors.orange,
|
||||
),
|
||||
title: Text(context.l10n.room_management),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showRoomLogin(context, contact, RoomLoginDestination.management);
|
||||
_showRoomLogin(
|
||||
context,
|
||||
contact,
|
||||
RoomLoginDestination.management,
|
||||
);
|
||||
},
|
||||
),
|
||||
] else ...[
|
||||
if(contact.pathLength > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: context.l10n.contacts_pathTraceTo(contact.name),
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
if (contact.pathLength > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: context.l10n.contacts_pathTraceTo(
|
||||
contact.name,
|
||||
),
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
),
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.chat),
|
||||
title: Text(context.l10n.contacts_openChat),
|
||||
|
|
@ -1051,7 +1085,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
ListTile(
|
||||
leading: const Icon(Icons.connect_without_contact),
|
||||
title: Text(context.l10n.contacts_ShareContactZeroHop),
|
||||
onTap: () {
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_contactZeroHop(contact.publicKey);
|
||||
},
|
||||
|
|
@ -1127,10 +1161,13 @@ class _ContactTile extends StatelessWidget {
|
|||
child: _buildContactAvatar(contact),
|
||||
),
|
||||
title: Text(contact.name),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(contact.pathLabel),
|
||||
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12))
|
||||
],),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(contact.pathLabel),
|
||||
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
// Clamp text scaling in trailing section to prevent overflow while
|
||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
||||
trailing: MediaQuery(
|
||||
|
|
@ -1154,8 +1191,8 @@ class _ContactTile extends StatelessWidget {
|
|||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -127,9 +127,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
|||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
|
|
@ -207,7 +205,6 @@ class _DeviceScreenState extends State<DeviceScreen>
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildBatteryIndicator(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
|
|
@ -224,11 +221,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
|||
final icon = _batteryIcon(percent);
|
||||
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
label: Text(displayLabel),
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
|
|
@ -260,25 +253,19 @@ class _DeviceScreenState extends State<DeviceScreen>
|
|||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ContactsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ChannelsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const MapScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,10 +56,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||
_updateEstimate();
|
||||
if (bounds != null) {
|
||||
_mapController.fitCamera(
|
||||
CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(48),
|
||||
),
|
||||
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -72,8 +69,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||
return;
|
||||
}
|
||||
final cacheService = context.read<MapTileCacheService>();
|
||||
final count =
|
||||
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom);
|
||||
final count = cacheService.estimateTileCount(
|
||||
_selectedBounds!,
|
||||
_minZoom,
|
||||
_maxZoom,
|
||||
);
|
||||
setState(() {
|
||||
_estimatedTiles = count;
|
||||
});
|
||||
|
|
@ -181,9 +181,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||
result.failed,
|
||||
)
|
||||
: context.l10n.mapCache_cachedTiles(result.downloaded);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Future<void> _clearCache() async {
|
||||
|
|
@ -224,10 +224,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.mapCache_title),
|
||||
centerTitle: true,
|
||||
),
|
||||
appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
@ -290,7 +287,10 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||
children: [
|
||||
Text(
|
||||
l10n.mapCache_cacheArea,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
|
|
@ -304,8 +304,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed:
|
||||
_isDownloading || selectedBounds == null ? null : _clearBounds,
|
||||
onPressed: _isDownloading || selectedBounds == null
|
||||
? null
|
||||
: _clearBounds,
|
||||
child: Text(l10n.common_clear),
|
||||
),
|
||||
],
|
||||
|
|
@ -313,11 +314,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.mapCache_zoomRange,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
RangeSlider(
|
||||
values:
|
||||
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()),
|
||||
values: RangeValues(
|
||||
_minZoom.toDouble(),
|
||||
_maxZoom.toDouble(),
|
||||
),
|
||||
min: 3,
|
||||
max: 18,
|
||||
divisions: 15,
|
||||
|
|
@ -341,10 +347,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: progressValue),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.mapCache_downloadedTiles(
|
||||
_completedTiles,
|
||||
_estimatedTiles,
|
||||
)),
|
||||
Text(
|
||||
l10n.mapCache_downloadedTiles(
|
||||
_completedTiles,
|
||||
_estimatedTiles,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -269,7 +269,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -278,85 +280,82 @@ class _MapScreenState extends State<MapScreen> {
|
|||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: center,
|
||||
initialZoom: initialZoom,
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
interactionOptions: InteractionOptions(
|
||||
flags: ~InteractiveFlag.rotate
|
||||
),
|
||||
onTap: (_, latLng) {
|
||||
if (_isSelectingPoi) {
|
||||
setState(() {
|
||||
_isSelectingPoi = false;
|
||||
});
|
||||
_shareMarker(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
defaultLabel: context.l10n.map_pointOfInterest,
|
||||
flags: 'poi',
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: (_, latLng) {
|
||||
if (_isSelectingPoi) {
|
||||
setState(() {
|
||||
_isSelectingPoi = false;
|
||||
});
|
||||
_shareMarker(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
defaultLabel: context.l10n.map_pointOfInterest,
|
||||
flags: 'poi',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_showShareMarkerAtPositionSheet(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
if (highlightPosition != null)
|
||||
Marker(
|
||||
point: highlightPosition,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.red[600],
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
..._buildMarkers(contactsWithLocation, settings),
|
||||
...sharedMarkers.map(_buildSharedMarker),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildLegend(
|
||||
contactsWithLocation.length,
|
||||
sharedMarkers.length,
|
||||
),
|
||||
],
|
||||
children: [
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: center,
|
||||
initialZoom: initialZoom,
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
interactionOptions: InteractionOptions(
|
||||
flags: ~InteractiveFlag.rotate,
|
||||
),
|
||||
onTap: (_, latLng) {
|
||||
if (_isSelectingPoi) {
|
||||
setState(() {
|
||||
_isSelectingPoi = false;
|
||||
});
|
||||
_shareMarker(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
defaultLabel: context.l10n.map_pointOfInterest,
|
||||
flags: 'poi',
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: (_, latLng) {
|
||||
if (_isSelectingPoi) {
|
||||
setState(() {
|
||||
_isSelectingPoi = false;
|
||||
});
|
||||
_shareMarker(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
defaultLabel: context.l10n.map_pointOfInterest,
|
||||
flags: 'poi',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_showShareMarkerAtPositionSheet(
|
||||
context: context,
|
||||
connector: connector,
|
||||
position: latLng,
|
||||
);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
if (highlightPosition != null)
|
||||
Marker(
|
||||
point: highlightPosition,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.red[600],
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
..._buildMarkers(contactsWithLocation, settings),
|
||||
...sharedMarkers.map(_buildSharedMarker),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: QuickSwitchBar(
|
||||
|
|
@ -376,7 +375,6 @@ class _MapScreenState extends State<MapScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
List<Marker> _buildMarkers(List<Contact> contacts, settings) {
|
||||
final markers = <Marker>[];
|
||||
|
||||
|
|
|
|||
|
|
@ -119,14 +119,24 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||
|
||||
// Show debug info if requested
|
||||
if (showDebug && mounted) {
|
||||
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
||||
DebugFrameViewer.showFrameDebug(context, frame, context.l10n.repeater_cliCommandFrameTitle);
|
||||
final frame = buildSendCliCommandFrame(
|
||||
widget.repeater.publicKey,
|
||||
command,
|
||||
);
|
||||
DebugFrameViewer.showFrameDebug(
|
||||
context,
|
||||
frame,
|
||||
context.l10n.repeater_cliCommandFrameTitle,
|
||||
);
|
||||
}
|
||||
|
||||
// Send CLI command to repeater with retry
|
||||
try {
|
||||
if (_commandService != null) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final connector = Provider.of<MeshCoreConnector>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final response = await _commandService!.sendCommand(
|
||||
repeater,
|
||||
|
|
@ -230,7 +240,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||
Text(l10n.repeater_cliTitle),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -251,12 +264,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -266,12 +287,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -282,7 +311,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
|
|
@ -473,7 +503,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||
decoration: InputDecoration(
|
||||
hintText: l10n.repeater_enterCommandHint,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
prefixText: '> ',
|
||||
),
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
|
|
@ -718,10 +751,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||
];
|
||||
|
||||
final gpsCommands = [
|
||||
_CommandHelpEntry(
|
||||
command: 'gps',
|
||||
description: l10n.repeater_cliHelpGps,
|
||||
),
|
||||
_CommandHelpEntry(command: 'gps', description: l10n.repeater_cliHelpGps),
|
||||
_CommandHelpEntry(
|
||||
command: 'gps {on|off}',
|
||||
description: l10n.repeater_cliHelpGpsOnOff,
|
||||
|
|
@ -758,13 +788,25 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_general, generalCommands),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_general,
|
||||
generalCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_settingsCategory,
|
||||
settingsCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_logging, loggingCommands),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_logging,
|
||||
loggingCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
|
|
@ -813,10 +855,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
note,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
Text(note, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
...commands.map((entry) => _buildHelpCommandCard(context, entry)),
|
||||
|
|
@ -871,8 +910,5 @@ class _CommandHelpEntry {
|
|||
final String command;
|
||||
final String description;
|
||||
|
||||
const _CommandHelpEntry({
|
||||
required this.command,
|
||||
required this.description,
|
||||
});
|
||||
const _CommandHelpEntry({required this.command, required this.description});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ class RepeaterStatusScreen extends StatefulWidget {
|
|||
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
static const int _statusPayloadOffset = 8;
|
||||
static const int _statusStatsSize = 52;
|
||||
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
|
||||
static const int _statusResponseBytes =
|
||||
_statusPayloadOffset + _statusStatsSize;
|
||||
|
||||
bool _isLoading = false;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
|
|
@ -293,7 +294,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
|
||||
content: Text(
|
||||
context.l10n.repeater_errorLoadingStatus(e.toString()),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
|
|
@ -327,7 +330,10 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
Text(l10n.repeater_statusTitle),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -348,12 +354,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -363,12 +377,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -379,7 +401,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
|
|
@ -423,11 +446,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Theme.of(context).textTheme.headlineSmall?.color),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_systemInformation,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -453,18 +482,30 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.radio, color: Theme.of(context).textTheme.headlineSmall?.color),
|
||||
Icon(
|
||||
Icons.radio,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_radioStatistics,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildInfoRow(l10n.repeater_lastRssi, _formatValue(_lastRssi, suffix: ' dB')),
|
||||
_buildInfoRow(
|
||||
l10n.repeater_lastRssi,
|
||||
_formatValue(_lastRssi, suffix: ' dB'),
|
||||
),
|
||||
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
|
||||
_buildInfoRow(l10n.repeater_noiseFloor, _formatValue(_noiseFloor, suffix: ' dB')),
|
||||
_buildInfoRow(
|
||||
l10n.repeater_noiseFloor,
|
||||
_formatValue(_noiseFloor, suffix: ' dB'),
|
||||
),
|
||||
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
|
||||
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
|
||||
],
|
||||
|
|
@ -483,11 +524,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.analytics, color: Theme.of(context).textTheme.headlineSmall?.color),
|
||||
Icon(
|
||||
Icons.analytics,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_packetStatistics,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -561,7 +608,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
if (_statusRequestedAt == null) return '—';
|
||||
final dt = _statusRequestedAt!;
|
||||
final date = '${dt.day}/${dt.month}/${dt.year}';
|
||||
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
final time =
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
return '$date $time';
|
||||
}
|
||||
|
||||
|
|
@ -598,7 +646,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
final direct = _formatValue(_dupDirect);
|
||||
return l10n.repeater_duplicatesFloodDirect(flood, direct);
|
||||
}
|
||||
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '—';
|
||||
if (_packetsRecv == null || _floodRx == null || _directRx == null)
|
||||
return '—';
|
||||
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
|
||||
if (dupTotal < 0) return '—';
|
||||
return l10n.repeater_duplicatesTotal(dupTotal);
|
||||
|
|
|
|||
|
|
@ -23,22 +23,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
|
||||
_connectionListener = () {
|
||||
if (connector.state == MeshCoreConnectionState.disconnected) {
|
||||
_changedNavigation = false;
|
||||
} else if (connector.state == MeshCoreConnectionState.connected && !_changedNavigation) {
|
||||
} else if (connector.state == MeshCoreConnectionState.connected &&
|
||||
!_changedNavigation) {
|
||||
_changedNavigation = true;
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ContactsScreen(),
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => const ContactsScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
connector.addListener(_connectionListener);
|
||||
}
|
||||
|
||||
|
|
@ -67,9 +66,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
_buildStatusBar(context, connector),
|
||||
|
||||
// Device list
|
||||
Expanded(
|
||||
child: _buildDeviceList(context, connector),
|
||||
),
|
||||
Expanded(child: _buildDeviceList(context, connector)),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
@ -77,8 +74,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
),
|
||||
floatingActionButton: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final isScanning = connector.state == MeshCoreConnectionState.scanning;
|
||||
|
||||
final isScanning =
|
||||
connector.state == MeshCoreConnectionState.scanning;
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
if (isScanning) {
|
||||
|
|
@ -87,7 +85,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
connector.startScan();
|
||||
}
|
||||
},
|
||||
icon: isScanning
|
||||
icon: isScanning
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
|
|
@ -97,7 +95,11 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_searching),
|
||||
label: Text(isScanning ? context.l10n.scanner_stop : context.l10n.scanner_scan),
|
||||
label: Text(
|
||||
isScanning
|
||||
? context.l10n.scanner_stop
|
||||
: context.l10n.scanner_scan,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -108,7 +110,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
String statusText;
|
||||
Color statusColor;
|
||||
|
||||
final l10n = context.l10n;
|
||||
final l10n = context.l10n;
|
||||
switch (connector.state) {
|
||||
case MeshCoreConnectionState.scanning:
|
||||
statusText = l10n.scanner_scanning;
|
||||
|
|
@ -155,20 +157,13 @@ final l10n = context.l10n;
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bluetooth,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
Icon(Icons.bluetooth, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
connector.state == MeshCoreConnectionState.scanning
|
||||
? context.l10n.scanner_searchingDevices
|
||||
: context.l10n.scanner_tapToScan,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -442,7 +442,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
bool isGPSEnabled = customVars["gps"] == "1";
|
||||
|
||||
// Read current interval or default to 900 (15 minutes)
|
||||
final currentInterval = int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
|
||||
final currentInterval =
|
||||
int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
|
||||
intervalController.text = currentInterval.toString();
|
||||
|
||||
showDialog(
|
||||
|
|
@ -782,9 +783,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
|||
|
||||
final maxTxPower = widget.connector.maxTxPower ?? 22;
|
||||
if (txPower == null || txPower < 0 || txPower > maxTxPower) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum AppDebugLogLevel {
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
}
|
||||
enum AppDebugLogLevel { info, warning, error }
|
||||
|
||||
class AppDebugLogEntry {
|
||||
final DateTime timestamp;
|
||||
|
|
@ -51,7 +47,11 @@ class AppDebugLogService extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
|
||||
void log(
|
||||
String message, {
|
||||
String tag = 'App',
|
||||
AppDebugLogLevel level = AppDebugLogLevel.info,
|
||||
}) {
|
||||
if (!_enabled) return;
|
||||
|
||||
_entries.add(
|
||||
|
|
|
|||
|
|
@ -82,10 +82,7 @@ class AppSettingsService extends ChangeNotifier {
|
|||
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
|
||||
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
|
||||
await updateSettings(
|
||||
_settings.copyWith(
|
||||
mapCacheMinZoom: safeMin,
|
||||
mapCacheMaxZoom: safeMax,
|
||||
),
|
||||
_settings.copyWith(mapCacheMinZoom: safeMin, mapCacheMaxZoom: safeMax),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -123,9 +120,16 @@ class AppSettingsService extends ChangeNotifier {
|
|||
appLogger.setEnabled(value);
|
||||
}
|
||||
|
||||
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
|
||||
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
|
||||
Future<void> setBatteryChemistryForDevice(
|
||||
String deviceId,
|
||||
String chemistry,
|
||||
) async {
|
||||
final updated = Map<String, String>.from(
|
||||
_settings.batteryChemistryByDeviceId,
|
||||
);
|
||||
updated[deviceId] = chemistry;
|
||||
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated));
|
||||
await updateSettings(
|
||||
_settings.copyWith(batteryChemistryByDeviceId: updated),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ class BleDebugLogService extends ChangeNotifier {
|
|||
return 'RESP_CODE_CHANNEL_INFO';
|
||||
case respCodeRadioSettings:
|
||||
return 'RESP_CODE_RADIO_SETTINGS';
|
||||
case pushCodeTraceData:
|
||||
case pushCodeTraceData:
|
||||
return 'PUSH_CODE_TRACE_DATA';
|
||||
default:
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -42,20 +42,21 @@ class MapTileCacheService {
|
|||
late final TileProvider tileProvider;
|
||||
|
||||
MapTileCacheService({BaseCacheManager? cacheManager})
|
||||
: cacheManager = cacheManager ??
|
||||
CacheManager(
|
||||
Config(
|
||||
cacheKey,
|
||||
stalePeriod: const Duration(days: 365),
|
||||
maxNrOfCacheObjects: 200000,
|
||||
),
|
||||
) {
|
||||
: cacheManager =
|
||||
cacheManager ??
|
||||
CacheManager(
|
||||
Config(
|
||||
cacheKey,
|
||||
stalePeriod: const Duration(days: 365),
|
||||
maxNrOfCacheObjects: 200000,
|
||||
),
|
||||
) {
|
||||
tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager);
|
||||
}
|
||||
|
||||
Map<String, String> get defaultHeaders => {
|
||||
'User-Agent': 'flutter_map ($userAgentPackageName)',
|
||||
};
|
||||
'User-Agent': 'flutter_map ($userAgentPackageName)',
|
||||
};
|
||||
|
||||
Future<void> clearCache() async {
|
||||
await cacheManager.emptyCache();
|
||||
|
|
@ -96,17 +97,21 @@ class MapTileCacheService {
|
|||
final future = cacheManager
|
||||
.downloadFile(url, key: url, authHeaders: authHeaders)
|
||||
.then((_) {
|
||||
completed += 1;
|
||||
}).catchError((_) {
|
||||
completed += 1;
|
||||
failed += 1;
|
||||
}).whenComplete(() {
|
||||
onProgress?.call(MapTileCacheProgress(
|
||||
completed: completed,
|
||||
total: total,
|
||||
failed: failed,
|
||||
));
|
||||
});
|
||||
completed += 1;
|
||||
})
|
||||
.catchError((_) {
|
||||
completed += 1;
|
||||
failed += 1;
|
||||
})
|
||||
.whenComplete(() {
|
||||
onProgress?.call(
|
||||
MapTileCacheProgress(
|
||||
completed: completed,
|
||||
total: total,
|
||||
failed: failed,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
pending.add(future);
|
||||
if (pending.length >= safeConcurrency) {
|
||||
|
|
@ -189,11 +194,9 @@ class MapTileCacheService {
|
|||
int _latToTileY(double lat, int zoom, int maxIndex) {
|
||||
final n = 1 << zoom;
|
||||
final rad = lat * math.pi / 180.0;
|
||||
final value = ((1 -
|
||||
math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) /
|
||||
2 *
|
||||
n)
|
||||
.floor();
|
||||
final value =
|
||||
((1 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2 * n)
|
||||
.floor();
|
||||
return value.clamp(0, maxIndex);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,10 +25,7 @@ class _AckHashMapping {
|
|||
final String messageId;
|
||||
final DateTime timestamp;
|
||||
|
||||
_AckHashMapping({
|
||||
required this.messageId,
|
||||
required this.timestamp,
|
||||
});
|
||||
_AckHashMapping({required this.messageId, required this.timestamp});
|
||||
}
|
||||
|
||||
class MessageRetryService extends ChangeNotifier {
|
||||
|
|
@ -39,11 +36,16 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final Map<String, Message> _pendingMessages = {};
|
||||
final Map<String, Contact> _pendingContacts = {};
|
||||
final Map<String, PathSelection> _pendingPathSelections = {};
|
||||
final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex → messageId + timestamp for O(1) lookup
|
||||
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history)
|
||||
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes
|
||||
final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||
final Map<String, String> _expectedHashToMessageId = {}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||
final Map<String, _AckHashMapping> _ackHashToMessageId =
|
||||
{}; // ackHashHex → messageId + timestamp for O(1) lookup
|
||||
final Map<String, List<Uint8List>> _expectedAckHashes =
|
||||
{}; // Track all expected ACKs for retries (for history)
|
||||
final List<_AckHistoryEntry> _ackHistory =
|
||||
[]; // Rolling buffer of recent ACK hashes
|
||||
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
||||
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||
final Map<String, String> _expectedHashToMessageId =
|
||||
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||
|
||||
Function(Contact, String, int, int)? _sendMessageCallback;
|
||||
Function(String, Message)? _addMessageCallback;
|
||||
|
|
@ -130,7 +132,8 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final messagePathBytes =
|
||||
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
|
||||
final messagePathLength =
|
||||
pathLength ?? _resolveMessagePathLength(contact, useFlood, pathSelection);
|
||||
pathLength ??
|
||||
_resolveMessagePathLength(contact, useFlood, pathSelection);
|
||||
final message = Message(
|
||||
senderKey: contact.publicKey,
|
||||
text: text,
|
||||
|
|
@ -167,15 +170,25 @@ class MessageRetryService extends ChangeNotifier {
|
|||
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
||||
if (message.pathLength != null && message.pathLength! < 0) {
|
||||
// Flood mode - clear the path
|
||||
debugPrint('Setting flood mode for retry attempt ${message.retryCount}');
|
||||
debugPrint(
|
||||
'Setting flood mode for retry attempt ${message.retryCount}',
|
||||
);
|
||||
_clearContactPathCallback!(contact);
|
||||
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
||||
// Specific path (including direct neighbor with pathLength=0)
|
||||
final pathStr = message.pathBytes.isEmpty
|
||||
? 'direct'
|
||||
: message.pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
|
||||
debugPrint('Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}');
|
||||
await _setContactPathCallback!(contact, message.pathBytes, message.pathLength!);
|
||||
: message.pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(',');
|
||||
debugPrint(
|
||||
'Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}',
|
||||
);
|
||||
await _setContactPathCallback!(
|
||||
contact,
|
||||
message.pathBytes,
|
||||
message.pathLength!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,22 +199,30 @@ class MessageRetryService extends ChangeNotifier {
|
|||
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
|
||||
final selfPubKey = _getSelfPublicKeyCallback?.call();
|
||||
if (selfPubKey != null) {
|
||||
final outboundText = _prepareContactOutboundTextCallback?.call(contact, message.text) ?? message.text;
|
||||
final outboundText =
|
||||
_prepareContactOutboundTextCallback?.call(contact, message.text) ??
|
||||
message.text;
|
||||
final expectedHash = MessageRetryService.computeExpectedAckHash(
|
||||
timestampSeconds,
|
||||
attempt,
|
||||
outboundText,
|
||||
selfPubKey,
|
||||
);
|
||||
final expectedHashHex = expectedHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
final expectedHashHex = expectedHash
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
_expectedHashToMessageId[expectedHashHex] = messageId;
|
||||
|
||||
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.info(
|
||||
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Computed expected ACK hash $expectedHashHex for message $messageId');
|
||||
debugPrint(
|
||||
'Computed expected ACK hash $expectedHashHex for message $messageId',
|
||||
);
|
||||
}
|
||||
|
||||
// DEPRECATED: Old queue-based matching (kept for fallback)
|
||||
|
|
@ -209,17 +230,14 @@ class MessageRetryService extends ChangeNotifier {
|
|||
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
|
||||
|
||||
if (_sendMessageCallback != null) {
|
||||
_sendMessageCallback!(
|
||||
contact,
|
||||
message.text,
|
||||
attempt,
|
||||
timestampSeconds,
|
||||
);
|
||||
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
|
||||
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
final ackHashHex = ackHash
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
// NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
|
||||
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
|
||||
|
|
@ -230,16 +248,21 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final message = _pendingMessages[messageId];
|
||||
|
||||
if (contact != null && message != null) {
|
||||
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.info(
|
||||
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Hash-based match: ACK hash $ackHashHex → message $messageId ✓');
|
||||
debugPrint(
|
||||
'Hash-based match: ACK hash $ackHashHex → message $messageId ✓',
|
||||
);
|
||||
|
||||
// Remove from old queue since we matched
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -259,7 +282,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Hash-based match failed for $ackHashHex, falling back to queue-based matching');
|
||||
debugPrint(
|
||||
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
||||
);
|
||||
|
||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
||||
final contactKey = entry.key;
|
||||
|
|
@ -271,7 +296,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||
messageId = candidateMessageId;
|
||||
contact = _pendingContacts[candidateMessageId];
|
||||
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey');
|
||||
debugPrint(
|
||||
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
||||
);
|
||||
break;
|
||||
} else {
|
||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||
|
|
@ -280,7 +307,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
if (_pendingMessages.containsKey(nextMessageId)) {
|
||||
messageId = nextMessageId;
|
||||
contact = _pendingContacts[nextMessageId];
|
||||
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId');
|
||||
debugPrint(
|
||||
'Queue-based match (fallback): $ackHashHex → message $messageId',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -306,16 +335,22 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final selection = _pendingPathSelections[messageId];
|
||||
|
||||
if (message == null) {
|
||||
debugPrint('Message $messageId no longer pending for ACK hash: $ackHashHex');
|
||||
debugPrint(
|
||||
'Message $messageId no longer pending for ACK hash: $ackHashHex',
|
||||
);
|
||||
_ackHashToMessageId.remove(ackHashHex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add this ACK hash to the list of expected ACKs for this message (for history)
|
||||
_expectedAckHashes[messageId] ??= [];
|
||||
if (!_expectedAckHashes[messageId]!.any((hash) => listEquals(hash, ackHash))) {
|
||||
if (!_expectedAckHashes[messageId]!.any(
|
||||
(hash) => listEquals(hash, ackHash),
|
||||
)) {
|
||||
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
|
||||
debugPrint('Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})');
|
||||
debugPrint(
|
||||
'Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})',
|
||||
);
|
||||
}
|
||||
|
||||
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
|
||||
|
|
@ -330,8 +365,13 @@ class MessageRetryService extends ChangeNotifier {
|
|||
} else {
|
||||
pathLengthValue = contact.pathLength;
|
||||
}
|
||||
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
|
||||
debugPrint('Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue');
|
||||
actualTimeout = _calculateTimeoutCallback!(
|
||||
pathLengthValue,
|
||||
message.text.length,
|
||||
);
|
||||
debugPrint(
|
||||
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
|
||||
);
|
||||
}
|
||||
|
||||
final updatedMessage = message.copyWith(
|
||||
|
|
@ -364,16 +404,22 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final selection = _pendingPathSelections[messageId];
|
||||
|
||||
if (message == null || contact == null) {
|
||||
debugPrint('Timeout fired but message $messageId no longer pending (likely already delivered)');
|
||||
debugPrint(
|
||||
'Timeout fired but message $messageId no longer pending (likely already delivered)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.warn(
|
||||
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
|
||||
debugPrint(
|
||||
'Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})',
|
||||
);
|
||||
|
||||
if (message.retryCount < maxRetries - 1) {
|
||||
final backoffMs = 1000 * (1 << message.retryCount);
|
||||
|
|
@ -402,7 +448,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
if (_pendingMessages.containsKey(messageId)) {
|
||||
_attemptSend(messageId);
|
||||
} else {
|
||||
debugPrint('Retry cancelled: message $messageId was delivered while waiting');
|
||||
debugPrint(
|
||||
'Retry cancelled: message $messageId was delivered while waiting',
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
|
@ -420,7 +468,8 @@ class MessageRetryService extends ChangeNotifier {
|
|||
|
||||
// Clean up the queue entry for this contact
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
|
||||
|
|
@ -430,7 +479,13 @@ class MessageRetryService extends ChangeNotifier {
|
|||
_clearContactPathCallback!(contact);
|
||||
}
|
||||
|
||||
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null);
|
||||
_recordPathResultFromMessage(
|
||||
contact.publicKeyHex,
|
||||
message,
|
||||
selection,
|
||||
false,
|
||||
null,
|
||||
);
|
||||
|
||||
if (_updateMessageCallback != null) {
|
||||
_updateMessageCallback!(failedMessage);
|
||||
|
|
@ -443,18 +498,22 @@ class MessageRetryService extends ChangeNotifier {
|
|||
void _moveAckHashesToHistory(String messageId) {
|
||||
final ackHashes = _expectedAckHashes.remove(messageId);
|
||||
if (ackHashes != null && ackHashes.isNotEmpty) {
|
||||
_ackHistory.add(_AckHistoryEntry(
|
||||
messageId: messageId,
|
||||
ackHashes: ackHashes,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
_ackHistory.add(
|
||||
_AckHistoryEntry(
|
||||
messageId: messageId,
|
||||
ackHashes: ackHashes,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// Trim history to max size (rolling buffer)
|
||||
while (_ackHistory.length > maxAckHistorySize) {
|
||||
_ackHistory.removeAt(0);
|
||||
}
|
||||
|
||||
debugPrint('Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})');
|
||||
debugPrint(
|
||||
'Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -462,7 +521,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
for (final entry in _ackHistory) {
|
||||
for (final expectedHash in entry.ackHashes) {
|
||||
if (listEquals(expectedHash, ackHash)) {
|
||||
debugPrint('Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s');
|
||||
debugPrint(
|
||||
'Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -472,7 +533,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
|
||||
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
|
||||
String? matchedMessageId;
|
||||
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
final ackHashHex = ackHash
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
|
||||
|
||||
|
|
@ -502,7 +565,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
tag: 'AckHash',
|
||||
);
|
||||
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
|
||||
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)');
|
||||
debugPrint(
|
||||
'ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)',
|
||||
);
|
||||
for (var entry in _expectedAckHashes.entries) {
|
||||
final messageId = entry.key;
|
||||
final expectedHashes = entry.value;
|
||||
|
|
@ -510,7 +575,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
for (final expectedHash in expectedHashes) {
|
||||
if (listEquals(expectedHash, ackHash)) {
|
||||
matchedMessageId = messageId;
|
||||
debugPrint('Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})');
|
||||
debugPrint(
|
||||
'Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -524,7 +591,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final contact = _pendingContacts[matchedMessageId];
|
||||
final selection = _pendingPathSelections[matchedMessageId];
|
||||
|
||||
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.info(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
|
||||
tag: 'AckHash',
|
||||
|
|
@ -549,8 +618,11 @@ class MessageRetryService extends ChangeNotifier {
|
|||
|
||||
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
||||
if (contact != null) {
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(matchedMessageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(
|
||||
matchedMessageId,
|
||||
);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
}
|
||||
|
|
@ -560,7 +632,13 @@ class MessageRetryService extends ChangeNotifier {
|
|||
}
|
||||
|
||||
if (contact != null) {
|
||||
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs);
|
||||
_recordPathResultFromMessage(
|
||||
contact.publicKeyHex,
|
||||
message,
|
||||
selection,
|
||||
true,
|
||||
tripTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
|
@ -663,7 +741,12 @@ class MessageRetryService extends ChangeNotifier {
|
|||
if (_recordPathResultCallback == null) return;
|
||||
final recordSelection = selection ?? _selectionFromMessage(message);
|
||||
if (recordSelection == null) return;
|
||||
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs);
|
||||
_recordPathResultCallback!(
|
||||
contactKey,
|
||||
recordSelection,
|
||||
success,
|
||||
tripTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
PathSelection? _selectionFromMessage(Message message) {
|
||||
|
|
|
|||
|
|
@ -6,13 +6,16 @@ class NotificationService {
|
|||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
bool _isInitialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
|
|
@ -47,16 +50,20 @@ class NotificationService {
|
|||
}
|
||||
|
||||
// Request Android 13+ notification permission
|
||||
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
final androidPlugin = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final granted = await androidPlugin.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
|
||||
// iOS permissions are requested during initialization
|
||||
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
final iosPlugin = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (iosPlugin != null) {
|
||||
final granted = await iosPlugin.requestPermissions(
|
||||
alert: true,
|
||||
|
|
@ -204,9 +211,7 @@ class NotificationService {
|
|||
);
|
||||
|
||||
final preview = message.trim();
|
||||
final body = preview.isEmpty
|
||||
? 'Received new message'
|
||||
: preview;
|
||||
final body = preview.isEmpty ? 'Received new message' : preview;
|
||||
|
||||
await _notifications.show(
|
||||
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,10 @@ class PathHistoryService extends ChangeNotifier {
|
|||
int? tripTimeMs,
|
||||
}) {
|
||||
if (selection.useFlood) {
|
||||
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
|
||||
final stats = _floodStats.putIfAbsent(
|
||||
contactPubKeyHex,
|
||||
() => _FloodStats(),
|
||||
);
|
||||
if (success) {
|
||||
stats.successCount += 1;
|
||||
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
|
||||
|
|
@ -88,23 +91,28 @@ class PathHistoryService extends ChangeNotifier {
|
|||
}
|
||||
|
||||
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
|
||||
final ranked = _getRankedPaths(contactPubKeyHex)
|
||||
.take(_autoRotationTopCount)
|
||||
.toList();
|
||||
final ranked = _getRankedPaths(
|
||||
contactPubKeyHex,
|
||||
).take(_autoRotationTopCount).toList();
|
||||
if (ranked.isEmpty) {
|
||||
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||
}
|
||||
|
||||
_trackAccess(contactPubKeyHex);
|
||||
|
||||
final selections = ranked
|
||||
.map((path) => PathSelection(
|
||||
pathBytes: path.pathBytes,
|
||||
hopCount: path.hopCount,
|
||||
useFlood: false,
|
||||
))
|
||||
.toList()
|
||||
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true));
|
||||
final selections =
|
||||
ranked
|
||||
.map(
|
||||
(path) => PathSelection(
|
||||
pathBytes: path.pathBytes,
|
||||
hopCount: path.hopCount,
|
||||
useFlood: false,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..add(
|
||||
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
|
||||
);
|
||||
|
||||
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
|
||||
final selection = selections[currentIndex % selections.length];
|
||||
|
|
@ -241,7 +249,8 @@ class PathHistoryService extends ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<ContactPathHistory?> _loadHistoryFromStorage(
|
||||
String contactPubKeyHex) async {
|
||||
String contactPubKeyHex,
|
||||
) async {
|
||||
return await _storage.loadPathHistory(contactPubKeyHex);
|
||||
}
|
||||
|
||||
|
|
@ -308,8 +317,10 @@ class PathHistoryService extends ChangeNotifier {
|
|||
..removeWhere((p) => p.pathBytes.isEmpty);
|
||||
|
||||
ranked.sort((a, b) {
|
||||
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2);
|
||||
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2);
|
||||
final aRate =
|
||||
(a.successCount + 1) / (a.successCount + a.failureCount + 2);
|
||||
final bRate =
|
||||
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
|
||||
if (aRate != bRate) return bRate.compareTo(aRate);
|
||||
if (a.successCount != b.successCount) {
|
||||
return b.successCount.compareTo(a.successCount);
|
||||
|
|
@ -329,7 +340,10 @@ class PathHistoryService extends ChangeNotifier {
|
|||
}
|
||||
|
||||
void _updateFloodStats(String contactPubKeyHex) {
|
||||
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
|
||||
final stats = _floodStats.putIfAbsent(
|
||||
contactPubKeyHex,
|
||||
() => _FloodStats(),
|
||||
);
|
||||
stats.lastUsed = DateTime.now();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ class RepeaterCommandService {
|
|||
int retries = maxRetries,
|
||||
}) async {
|
||||
final repeaterKey = repeater.publicKeyHex;
|
||||
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
|
||||
final hasPending = _pendingCommands.keys.any(
|
||||
(id) => id.startsWith(repeaterKey),
|
||||
);
|
||||
if (hasPending) {
|
||||
throw Exception('Another command is still awaiting a response.');
|
||||
}
|
||||
|
|
@ -84,7 +86,9 @@ class RepeaterCommandService {
|
|||
attempt: attempt,
|
||||
timestampSeconds: timestampSeconds,
|
||||
);
|
||||
final responseBytes = frame.length > maxFrameSize ? frame.length : maxFrameSize;
|
||||
final responseBytes = frame.length > maxFrameSize
|
||||
? frame.length
|
||||
: maxFrameSize;
|
||||
final timeoutMs = _connector.calculateTimeout(
|
||||
pathLength: pathLengthValue,
|
||||
messageBytes: responseBytes,
|
||||
|
|
@ -97,7 +101,9 @@ class RepeaterCommandService {
|
|||
() {
|
||||
final completer = _pendingCommands[commandId];
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.completeError('Command timeout after $timeoutSeconds seconds');
|
||||
completer.completeError(
|
||||
'Command timeout after $timeoutSeconds seconds',
|
||||
);
|
||||
_cleanup(commandId);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ class StorageService {
|
|||
static const String _repeaterPasswordsKey = 'repeater_passwords';
|
||||
|
||||
Future<void> savePathHistory(
|
||||
String contactPubKeyHex, ContactPathHistory history) async {
|
||||
String contactPubKeyHex,
|
||||
ContactPathHistory history,
|
||||
) async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_pathHistoryPrefix$contactPubKeyHex';
|
||||
final jsonStr = jsonEncode(history.toJson());
|
||||
|
|
@ -39,8 +41,9 @@ class StorageService {
|
|||
Future<void> clearAllPathHistories() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final keys = prefs.getKeys();
|
||||
final pathHistoryKeys =
|
||||
keys.where((key) => key.startsWith(_pathHistoryPrefix));
|
||||
final pathHistoryKeys = keys.where(
|
||||
(key) => key.startsWith(_pathHistoryPrefix),
|
||||
);
|
||||
|
||||
for (final key in pathHistoryKeys) {
|
||||
await prefs.remove(key);
|
||||
|
|
@ -74,7 +77,9 @@ class StorageService {
|
|||
|
||||
/// Save a repeater password by public key hex
|
||||
Future<void> saveRepeaterPassword(
|
||||
String repeaterPubKeyHex, String password) async {
|
||||
String repeaterPubKeyHex,
|
||||
String password,
|
||||
) async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final passwords = await loadRepeaterPasswords();
|
||||
passwords[repeaterPubKeyHex] = password;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ class ChannelMessageStore {
|
|||
static const String _keyPrefix = 'channel_messages_';
|
||||
|
||||
/// Save messages for a specific channel
|
||||
Future<void> saveChannelMessages(int channelIndex, List<ChannelMessage> messages) async {
|
||||
Future<void> saveChannelMessages(
|
||||
int channelIndex,
|
||||
List<ChannelMessage> messages,
|
||||
) async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$channelIndex';
|
||||
|
||||
|
|
@ -96,7 +99,8 @@ class ChannelMessageStore {
|
|||
pathVariants: (json['pathVariants'] as List<dynamic>?)
|
||||
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
|
||||
.toList(),
|
||||
repeats: (json['repeats'] as List<dynamic>?)
|
||||
repeats:
|
||||
(json['repeats'] as List<dynamic>?)
|
||||
?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
|
|
@ -105,15 +109,19 @@ class ChannelMessageStore {
|
|||
replyToMessageId: json['replyToMessageId'] as String?,
|
||||
replyToSenderName: json['replyToSenderName'] as String?,
|
||||
replyToText: json['replyToText'] as String?,
|
||||
reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
|
||||
(key, value) => MapEntry(key, value as int),
|
||||
) ?? {},
|
||||
reactions:
|
||||
(json['reactions'] as Map<String, dynamic>?)?.map(
|
||||
(key, value) => MapEntry(key, value as int),
|
||||
) ??
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _repeatToJson(Repeat repeat) {
|
||||
return {
|
||||
'repeaterKey': repeat.repeaterKey != null ? base64Encode(repeat.repeaterKey!) : null,
|
||||
'repeaterKey': repeat.repeaterKey != null
|
||||
? base64Encode(repeat.repeaterKey!)
|
||||
: null,
|
||||
'repeaterName': repeat.repeaterName,
|
||||
'tripTimeMs': repeat.tripTimeMs,
|
||||
'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [],
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ class ChannelOrderStore {
|
|||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
return decoded.map((value) => value is int ? value : int.tryParse('$value')).whereType<int>().toList();
|
||||
return decoded
|
||||
.map((value) => value is int ? value : int.tryParse('$value'))
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {
|
||||
// fall through to legacy parse
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class CommunityStore {
|
|||
/// Add a new community
|
||||
Future<void> addCommunity(Community community) async {
|
||||
final communities = await loadCommunities();
|
||||
|
||||
|
||||
// Check if community with same ID already exists
|
||||
final existingIndex = communities.indexWhere((c) => c.id == community.id);
|
||||
if (existingIndex >= 0) {
|
||||
|
|
@ -49,7 +49,7 @@ class CommunityStore {
|
|||
} else {
|
||||
communities.add(community);
|
||||
}
|
||||
|
||||
|
||||
await saveCommunities(communities);
|
||||
}
|
||||
|
||||
|
|
@ -92,10 +92,7 @@ class CommunityStore {
|
|||
}
|
||||
|
||||
/// Add a hashtag channel to a community
|
||||
Future<void> addHashtagChannel(
|
||||
String communityId,
|
||||
String hashtag,
|
||||
) async {
|
||||
Future<void> addHashtagChannel(String communityId, String hashtag) async {
|
||||
final community = await getCommunity(communityId);
|
||||
if (community != null) {
|
||||
final updated = community.addHashtagChannel(hashtag);
|
||||
|
|
@ -104,10 +101,7 @@ class CommunityStore {
|
|||
}
|
||||
|
||||
/// Remove a hashtag channel from a community
|
||||
Future<void> removeHashtagChannel(
|
||||
String communityId,
|
||||
String hashtag,
|
||||
) async {
|
||||
Future<void> removeHashtagChannel(String communityId, String hashtag) async {
|
||||
final community = await getCommunity(communityId);
|
||||
if (community != null) {
|
||||
final updated = community.removeHashtagChannel(hashtag);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ class ContactStore {
|
|||
|
||||
try {
|
||||
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
|
||||
return jsonList.map((entry) => _fromJson(entry as Map<String, dynamic>)).toList();
|
||||
return jsonList
|
||||
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -57,12 +59,16 @@ class ContactStore {
|
|||
: Uint8List(0),
|
||||
pathOverride: json['pathOverride'] as int?,
|
||||
pathOverrideBytes: json['pathOverrideBytes'] != null
|
||||
? Uint8List.fromList(base64Decode(json['pathOverrideBytes'] as String))
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['pathOverrideBytes'] as String),
|
||||
)
|
||||
: null,
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
||||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(lastMessageMs ?? lastSeenMs),
|
||||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
lastMessageMs ?? lastSeenMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import 'prefs_manager.dart';
|
|||
class MessageStore {
|
||||
static const String _keyPrefix = 'messages_';
|
||||
|
||||
Future<void> saveMessages(String contactKeyHex, List<Message> messages) async {
|
||||
Future<void> saveMessages(
|
||||
String contactKeyHex,
|
||||
List<Message> messages,
|
||||
) async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$contactKeyHex';
|
||||
final jsonList = messages.map(_messageToJson).toList();
|
||||
|
|
@ -45,12 +48,16 @@ class MessageStore {
|
|||
'messageId': msg.messageId,
|
||||
'retryCount': msg.retryCount,
|
||||
'estimatedTimeoutMs': msg.estimatedTimeoutMs,
|
||||
'expectedAckHash': msg.expectedAckHash != null ? base64Encode(msg.expectedAckHash!) : null,
|
||||
'expectedAckHash': msg.expectedAckHash != null
|
||||
? base64Encode(msg.expectedAckHash!)
|
||||
: null,
|
||||
'sentAt': msg.sentAt?.millisecondsSinceEpoch,
|
||||
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
|
||||
'tripTimeMs': msg.tripTimeMs,
|
||||
'pathLength': msg.pathLength,
|
||||
'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null,
|
||||
'pathBytes': msg.pathBytes.isNotEmpty
|
||||
? base64Encode(msg.pathBytes)
|
||||
: null,
|
||||
'reactions': msg.reactions,
|
||||
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
|
||||
};
|
||||
|
|
@ -59,7 +66,9 @@ class MessageStore {
|
|||
Message _messageFromJson(Map<String, dynamic> json) {
|
||||
final rawText = json['text'] as String;
|
||||
final isCli = json['isCli'] as bool? ?? false;
|
||||
final decodedText = isCli ? rawText : (Smaz.tryDecodePrefixed(rawText) ?? rawText);
|
||||
final decodedText = isCli
|
||||
? rawText
|
||||
: (Smaz.tryDecodePrefixed(rawText) ?? rawText);
|
||||
return Message(
|
||||
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
|
||||
text: decodedText,
|
||||
|
|
@ -84,11 +93,15 @@ class MessageStore {
|
|||
pathBytes: json['pathBytes'] != null
|
||||
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
|
||||
: Uint8List(0),
|
||||
reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
|
||||
(key, value) => MapEntry(key, value as int),
|
||||
) ?? {},
|
||||
reactions:
|
||||
(json['reactions'] as Map<String, dynamic>?)?.map(
|
||||
(key, value) => MapEntry(key, value as int),
|
||||
) ??
|
||||
{},
|
||||
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
|
||||
? Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String))
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['fourByteRoomContactKey'] as String),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ class PrefsManager {
|
|||
static SharedPreferences get instance {
|
||||
if (_instance == null) {
|
||||
throw StateError(
|
||||
'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.');
|
||||
'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.',
|
||||
);
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,8 +92,9 @@ class UnreadStore {
|
|||
if (_pendingChannelLastRead == null) return;
|
||||
|
||||
final prefs = PrefsManager.instance;
|
||||
final asString =
|
||||
_pendingChannelLastRead!.map((key, value) => MapEntry(key.toString(), value));
|
||||
final asString = _pendingChannelLastRead!.map(
|
||||
(key, value) => MapEntry(key.toString(), value),
|
||||
);
|
||||
final jsonStr = jsonEncode(asString);
|
||||
await prefs.setString(_channelLastReadKey, jsonStr);
|
||||
_pendingChannelLastRead = null;
|
||||
|
|
@ -104,9 +105,6 @@ class UnreadStore {
|
|||
_contactSaveTimer?.cancel();
|
||||
_channelSaveTimer?.cancel();
|
||||
|
||||
await Future.wait([
|
||||
_flushContactLastRead(),
|
||||
_flushChannelLastRead(),
|
||||
]);
|
||||
await Future.wait([_flushContactLastRead(), _flushChannelLastRead()]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ class AppLogger {
|
|||
}
|
||||
|
||||
/// Log a message with custom level
|
||||
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
|
||||
void log(
|
||||
String message, {
|
||||
String tag = 'App',
|
||||
AppDebugLogLevel level = AppDebugLogLevel.info,
|
||||
}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.log(message, tag: tag, level: level);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,10 +29,7 @@ BatteryUi batteryUiForPercent(int? percent) {
|
|||
class BatteryIndicator extends StatefulWidget {
|
||||
final MeshCoreConnector connector;
|
||||
|
||||
const BatteryIndicator({
|
||||
super.key,
|
||||
required this.connector,
|
||||
});
|
||||
const BatteryIndicator({super.key, required this.connector});
|
||||
|
||||
@override
|
||||
State<BatteryIndicator> createState() => _BatteryIndicatorState();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import '../connector/meshcore_protocol.dart';
|
|||
|
||||
/// Debug widget to show the hex dump of a frame
|
||||
class DebugFrameViewer {
|
||||
static void showFrameDebug(BuildContext context, Uint8List frame, String title) {
|
||||
static void showFrameDebug(
|
||||
BuildContext context,
|
||||
Uint8List frame,
|
||||
String title,
|
||||
) {
|
||||
final hexString = frame
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(' ');
|
||||
|
|
@ -14,16 +18,26 @@ class DebugFrameViewer {
|
|||
details.writeln(context.l10n.debugFrame_length(frame.length));
|
||||
details.writeln('');
|
||||
details.writeln(
|
||||
context.l10n.debugFrame_command(frame[0].toRadixString(16).padLeft(2, '0')),
|
||||
context.l10n.debugFrame_command(
|
||||
frame[0].toRadixString(16).padLeft(2, '0'),
|
||||
),
|
||||
);
|
||||
|
||||
if (frame[0] == cmdSendTxtMsg && frame.length > 37) {
|
||||
details.writeln('');
|
||||
details.writeln(context.l10n.debugFrame_textMessageHeader);
|
||||
details.writeln(context.l10n.debugFrame_destinationPubKey(pubKeyToHex(frame.sublist(1, 33))));
|
||||
details.writeln(context.l10n.debugFrame_timestamp(readUint32LE(frame, 33)));
|
||||
details.writeln(
|
||||
context.l10n.debugFrame_flags(frame[37].toRadixString(16).padLeft(2, '0')),
|
||||
context.l10n.debugFrame_destinationPubKey(
|
||||
pubKeyToHex(frame.sublist(1, 33)),
|
||||
),
|
||||
);
|
||||
details.writeln(
|
||||
context.l10n.debugFrame_timestamp(readUint32LE(frame, 33)),
|
||||
);
|
||||
details.writeln(
|
||||
context.l10n.debugFrame_flags(
|
||||
frame[37].toRadixString(16).padLeft(2, '0'),
|
||||
),
|
||||
);
|
||||
final txtType = (frame[37] >> 2) & 0x03;
|
||||
final typeLabel = txtType == txtTypeCliData
|
||||
|
|
@ -34,7 +48,7 @@ class DebugFrameViewer {
|
|||
final textBytes = frame.sublist(38);
|
||||
final nullIdx = textBytes.indexOf(0);
|
||||
final text = String.fromCharCodes(
|
||||
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes
|
||||
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes,
|
||||
);
|
||||
details.writeln(context.l10n.debugFrame_text(text));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,18 +7,14 @@ class DeviceTile extends StatelessWidget {
|
|||
final ScanResult scanResult;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const DeviceTile({
|
||||
super.key,
|
||||
required this.scanResult,
|
||||
required this.onTap,
|
||||
});
|
||||
const DeviceTile({super.key, required this.scanResult, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final device = scanResult.device;
|
||||
final rssi = scanResult.rssi;
|
||||
final name = device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
final name = device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
: scanResult.advertisementData.advName;
|
||||
|
||||
return ListTile(
|
||||
|
|
@ -58,12 +54,8 @@ class DeviceTile extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
Text(
|
||||
'$rssi dBm',
|
||||
style: TextStyle(fontSize: 10, color: color),
|
||||
),
|
||||
Text('$rssi dBm', style: TextStyle(fontSize: 10, color: color)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,32 +5,196 @@ import '../l10n/l10n.dart';
|
|||
class EmojiPicker extends StatelessWidget {
|
||||
final Function(String) onEmojiSelected;
|
||||
|
||||
const EmojiPicker({
|
||||
super.key,
|
||||
required this.onEmojiSelected,
|
||||
});
|
||||
const EmojiPicker({super.key, required this.onEmojiSelected});
|
||||
|
||||
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
|
||||
|
||||
static const List<String> smileys = [
|
||||
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘',
|
||||
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏',
|
||||
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡',
|
||||
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
|
||||
];
|
||||
'😀',
|
||||
'😃',
|
||||
'😄',
|
||||
'😁',
|
||||
'😅',
|
||||
'😂',
|
||||
'🤣',
|
||||
'😊',
|
||||
'😇',
|
||||
'🙂',
|
||||
'🙃',
|
||||
'😉',
|
||||
'😌',
|
||||
'😍',
|
||||
'🥰',
|
||||
'😘',
|
||||
'😗',
|
||||
'😙',
|
||||
'😚',
|
||||
'😋',
|
||||
'😛',
|
||||
'😝',
|
||||
'😜',
|
||||
'🤪',
|
||||
'🤨',
|
||||
'🧐',
|
||||
'🤓',
|
||||
'😎',
|
||||
'🥸',
|
||||
'🤩',
|
||||
'🥳',
|
||||
'😏',
|
||||
'😒',
|
||||
'😞',
|
||||
'😔',
|
||||
'😟',
|
||||
'😕',
|
||||
'🙁',
|
||||
'😣',
|
||||
'😖',
|
||||
'😫',
|
||||
'😩',
|
||||
'🥺',
|
||||
'😢',
|
||||
'😭',
|
||||
'😤',
|
||||
'😠',
|
||||
'😡',
|
||||
'🤬',
|
||||
'🤯',
|
||||
'😳',
|
||||
'🥵',
|
||||
'🥶',
|
||||
'😱',
|
||||
'😨',
|
||||
'😰',
|
||||
'😥',
|
||||
'😓',
|
||||
'🤗',
|
||||
'🤔',
|
||||
'🤭',
|
||||
'🤫',
|
||||
'🤥',
|
||||
'😶',
|
||||
];
|
||||
static const List<String> gestures = [
|
||||
'👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆',
|
||||
'👇', '☝️', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪',
|
||||
];
|
||||
'👍',
|
||||
'👎',
|
||||
'👊',
|
||||
'✊',
|
||||
'🤛',
|
||||
'🤜',
|
||||
'🤞',
|
||||
'✌️',
|
||||
'🤟',
|
||||
'🤘',
|
||||
'👌',
|
||||
'🤌',
|
||||
'🤏',
|
||||
'👈',
|
||||
'👉',
|
||||
'👆',
|
||||
'👇',
|
||||
'☝️',
|
||||
'👋',
|
||||
'🤚',
|
||||
'🖐️',
|
||||
'✋',
|
||||
'🖖',
|
||||
'👏',
|
||||
'🙌',
|
||||
'👐',
|
||||
'🤲',
|
||||
'🤝',
|
||||
'🙏',
|
||||
'✍️',
|
||||
'💅',
|
||||
'🤳',
|
||||
'💪',
|
||||
];
|
||||
static const List<String> hearts = [
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️🔥', '❤️🩹', '💕', '💞', '💓', '💗',
|
||||
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️🗨️', '🗨️', '🗯️', '💭',
|
||||
];
|
||||
'❤️',
|
||||
'🧡',
|
||||
'💛',
|
||||
'💚',
|
||||
'💙',
|
||||
'💜',
|
||||
'🖤',
|
||||
'🤍',
|
||||
'🤎',
|
||||
'💔',
|
||||
'❤️🔥',
|
||||
'❤️🩹',
|
||||
'💕',
|
||||
'💞',
|
||||
'💓',
|
||||
'💗',
|
||||
'💖',
|
||||
'💘',
|
||||
'💝',
|
||||
'💟',
|
||||
'💌',
|
||||
'💢',
|
||||
'💥',
|
||||
'💫',
|
||||
'💦',
|
||||
'💨',
|
||||
'🕳️',
|
||||
'💬',
|
||||
'👁️🗨️',
|
||||
'🗨️',
|
||||
'🗯️',
|
||||
'💭',
|
||||
];
|
||||
static const List<String> objects = [
|
||||
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '⚽', '⚾', '🥎', '🏀', '🏐',
|
||||
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '⛳', '🔥',
|
||||
'⭐', '🌟', '✨', '⚡', '💡', '🔦', '🏮', '🪔', '📱', '💻', '⌚', '📷', '📺', '📻', '🎵', '🎶', '🚀',
|
||||
];
|
||||
'🎉',
|
||||
'🎊',
|
||||
'🎈',
|
||||
'🎁',
|
||||
'🎀',
|
||||
'🪅',
|
||||
'🪆',
|
||||
'🏆',
|
||||
'🥇',
|
||||
'🥈',
|
||||
'🥉',
|
||||
'⚽',
|
||||
'⚾',
|
||||
'🥎',
|
||||
'🏀',
|
||||
'🏐',
|
||||
'🏈',
|
||||
'🏉',
|
||||
'🎾',
|
||||
'🥏',
|
||||
'🎳',
|
||||
'🏏',
|
||||
'🏑',
|
||||
'🏒',
|
||||
'🥍',
|
||||
'🏓',
|
||||
'🏸',
|
||||
'🥊',
|
||||
'🥋',
|
||||
'🥅',
|
||||
'⛳',
|
||||
'🔥',
|
||||
'⭐',
|
||||
'🌟',
|
||||
'✨',
|
||||
'⚡',
|
||||
'💡',
|
||||
'🔦',
|
||||
'🏮',
|
||||
'🪔',
|
||||
'📱',
|
||||
'💻',
|
||||
'⌚',
|
||||
'📷',
|
||||
'📺',
|
||||
'📻',
|
||||
'🎵',
|
||||
'🎶',
|
||||
'🚀',
|
||||
];
|
||||
|
||||
Map<String, List<String>> _emojiCategories(AppLocalizations l10n) {
|
||||
return {
|
||||
|
|
@ -60,7 +224,10 @@ class EmojiPicker extends StatelessWidget {
|
|||
children: [
|
||||
Text(
|
||||
l10n.chat_addReaction,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
|
|
@ -83,7 +250,9 @@ class EmojiPicker extends StatelessWidget {
|
|||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
|
|
@ -114,11 +283,12 @@ class EmojiPicker extends StatelessWidget {
|
|||
.map(
|
||||
(emojis) => GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 8,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 8,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemCount: emojis.length,
|
||||
itemBuilder: (context, index) => InkWell(
|
||||
onTap: () {
|
||||
|
|
|
|||
|
|
@ -23,10 +23,7 @@ class EmptyState extends StatelessWidget {
|
|||
children: [
|
||||
Icon(icon, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
Text(title, style: TextStyle(fontSize: 16, color: Colors.grey[600])),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
|
|
@ -35,10 +32,7 @@ class EmptyState extends StatelessWidget {
|
|||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
action!,
|
||||
],
|
||||
if (action != null) ...[const SizedBox(height: 24), action!],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ import '../l10n/l10n.dart';
|
|||
class GifPicker extends StatefulWidget {
|
||||
final Function(String gifId) onGifSelected;
|
||||
|
||||
const GifPicker({
|
||||
super.key,
|
||||
required this.onGifSelected,
|
||||
});
|
||||
const GifPicker({super.key, required this.onGifSelected});
|
||||
|
||||
@override
|
||||
State<GifPicker> createState() => _GifPickerState();
|
||||
|
|
@ -45,11 +42,13 @@ class _GifPickerState extends State<GifPicker> {
|
|||
});
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g',
|
||||
),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
final response = await http
|
||||
.get(
|
||||
Uri.parse(
|
||||
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g',
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
|
|
@ -85,11 +84,13 @@ class _GifPickerState extends State<GifPicker> {
|
|||
});
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=25&rating=g',
|
||||
),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
final response = await http
|
||||
.get(
|
||||
Uri.parse(
|
||||
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=25&rating=g',
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
|
|
@ -127,7 +128,10 @@ class _GifPickerState extends State<GifPicker> {
|
|||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.gifPicker_title,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
|
|
@ -170,18 +174,13 @@ class _GifPickerState extends State<GifPicker> {
|
|||
const SizedBox(height: 16),
|
||||
|
||||
// GIF grid
|
||||
Expanded(
|
||||
child: _buildContent(),
|
||||
),
|
||||
Expanded(child: _buildContent()),
|
||||
|
||||
// Powered by Giphy attribution
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.gifPicker_poweredBy,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -190,9 +189,7 @@ class _GifPickerState extends State<GifPicker> {
|
|||
|
||||
Widget _buildContent() {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
|
|
@ -244,7 +241,8 @@ class _GifPickerState extends State<GifPicker> {
|
|||
itemBuilder: (context, index) {
|
||||
final gif = _gifs[index];
|
||||
final gifId = gif['id'] as String;
|
||||
final previewUrl = gif['images']?['fixed_height_small']?['url'] as String?;
|
||||
final previewUrl =
|
||||
gif['images']?['fixed_height_small']?['url'] as String?;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
|
|
@ -265,20 +263,16 @@ class _GifPickerState extends State<GifPicker> {
|
|||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Center(
|
||||
child: Icon(Icons.error_outline),
|
||||
);
|
||||
return const Center(child: Icon(Icons.error_outline));
|
||||
},
|
||||
)
|
||||
: const Center(
|
||||
child: Icon(Icons.gif_box),
|
||||
),
|
||||
: const Center(child: Icon(Icons.gif_box)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ import '../helpers/chat_scroll_controller.dart';
|
|||
class JumpToBottomButton extends StatelessWidget {
|
||||
final ChatScrollController scrollController;
|
||||
|
||||
const JumpToBottomButton({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
});
|
||||
const JumpToBottomButton({super.key, required this.scrollController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
enum ContactSortOption {
|
||||
lastSeen,
|
||||
recentMessages,
|
||||
name,
|
||||
}
|
||||
enum ContactSortOption { lastSeen, recentMessages, name }
|
||||
|
||||
enum ContactTypeFilter {
|
||||
all,
|
||||
users,
|
||||
repeaters,
|
||||
rooms,
|
||||
}
|
||||
enum ContactTypeFilter { all, users, repeaters, rooms }
|
||||
|
||||
class SortFilterMenuOption {
|
||||
final int value;
|
||||
|
|
@ -30,10 +21,7 @@ class SortFilterMenuSection {
|
|||
final String title;
|
||||
final List<SortFilterMenuOption> options;
|
||||
|
||||
const SortFilterMenuSection({
|
||||
required this.title,
|
||||
required this.options,
|
||||
});
|
||||
const SortFilterMenuSection({required this.title, required this.options});
|
||||
}
|
||||
|
||||
class SortFilterMenu extends StatelessWidget {
|
||||
|
|
@ -62,7 +50,9 @@ class SortFilterMenu extends StatelessWidget {
|
|||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
final visibleSections = sections.where((section) => section.options.isNotEmpty).toList();
|
||||
final visibleSections = sections
|
||||
.where((section) => section.options.isNotEmpty)
|
||||
.toList();
|
||||
final entries = <PopupMenuEntry<int>>[];
|
||||
for (int i = 0; i < visibleSections.length; i++) {
|
||||
final section = visibleSections[i];
|
||||
|
|
|
|||
|
|
@ -10,15 +10,10 @@ import '../services/path_history_service.dart';
|
|||
import 'path_selection_dialog.dart';
|
||||
|
||||
class PathManagementDialog {
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required Contact contact,
|
||||
}) {
|
||||
static Future<void> show(BuildContext context, {required Contact contact}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _PathManagementDialog(
|
||||
contact: contact,
|
||||
),
|
||||
builder: (context) => _PathManagementDialog(contact: contact),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,9 +21,7 @@ class PathManagementDialog {
|
|||
class _PathManagementDialog extends StatelessWidget {
|
||||
final Contact contact;
|
||||
|
||||
const _PathManagementDialog({
|
||||
required this.contact,
|
||||
});
|
||||
const _PathManagementDialog({required this.contact});
|
||||
|
||||
Contact _resolveContact(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
|
|
@ -83,7 +76,9 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
Contact currentContact,
|
||||
) async {
|
||||
final l10n = context.l10n;
|
||||
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
|
||||
if (currentContact.pathLength > 0 &&
|
||||
currentContact.path.isEmpty &&
|
||||
connector.isConnected) {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
||||
|
|
@ -140,13 +135,19 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
if (paths.isNotEmpty) ...[
|
||||
Text(
|
||||
l10n.chat_recentAckPaths,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (paths.length >= 100) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amberAccent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
@ -165,7 +166,9 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
|
||||
backgroundColor: path.wasFloodDiscovery
|
||||
? Colors.blue
|
||||
: Colors.green,
|
||||
child: Text(
|
||||
'${path.hopCount}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
|
|
@ -193,16 +196,27 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
path.wasFloodDiscovery
|
||||
? const Icon(Icons.waves, size: 16, color: Colors.grey)
|
||||
: const Icon(Icons.route, size: 16, color: Colors.grey),
|
||||
? const Icon(
|
||||
Icons.waves,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.route,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
onLongPress: () => _showFullPathDialog(context, path.pathBytes),
|
||||
onLongPress: () =>
|
||||
_showFullPathDialog(context, path.pathBytes),
|
||||
onTap: () async {
|
||||
if (path.pathBytes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.chat_pathDetailsNotAvailable),
|
||||
content: Text(
|
||||
l10n.chat_pathDetailsNotAvailable,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
@ -222,7 +236,9 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.path_usingHopsPath(path.hopCount)),
|
||||
content: Text(
|
||||
l10n.path_usingHopsPath(path.hopCount),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
@ -238,7 +254,10 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.chat_pathActions,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
|
|
@ -248,8 +267,14 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
backgroundColor: Colors.purple,
|
||||
child: Icon(Icons.edit_road, size: 16),
|
||||
),
|
||||
title: Text(l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
title: Text(
|
||||
l10n.chat_setCustomPath,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.chat_setCustomPathSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await _setCustomPath(context, connector, currentContact);
|
||||
},
|
||||
|
|
@ -261,8 +286,14 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
backgroundColor: Colors.orange,
|
||||
child: Icon(Icons.clear_all, size: 16),
|
||||
),
|
||||
title: Text(l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
title: Text(
|
||||
l10n.chat_clearPath,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.chat_clearPathSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.clearContactPath(currentContact);
|
||||
if (!context.mounted) return;
|
||||
|
|
@ -282,10 +313,19 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.waves, size: 16),
|
||||
),
|
||||
title: Text(l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
title: Text(
|
||||
l10n.chat_forceFloodMode,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.chat_floodModeSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.setPathOverride(currentContact, pathLen: -1);
|
||||
await connector.setPathOverride(
|
||||
currentContact,
|
||||
pathLen: -1,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
|
|||
|
|
@ -70,12 +70,15 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
}
|
||||
|
||||
void _updateTextFromContacts() {
|
||||
final pathParts = _selectedContacts.map((contact) {
|
||||
if (contact.publicKeyHex.length >= 2) {
|
||||
return contact.publicKeyHex.substring(0, 2);
|
||||
}
|
||||
return '';
|
||||
}).where((s) => s.isNotEmpty).toList();
|
||||
final pathParts = _selectedContacts
|
||||
.map((contact) {
|
||||
if (contact.publicKeyHex.length >= 2) {
|
||||
return contact.publicKeyHex.substring(0, 2);
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
_controller.text = pathParts.join(',');
|
||||
}
|
||||
|
|
@ -107,7 +110,11 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
}
|
||||
|
||||
// Parse comma-separated hex prefixes
|
||||
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
final pathIds = path
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
final pathBytesList = <int>[];
|
||||
final invalidPrefixes = <String>[];
|
||||
|
||||
|
|
@ -132,7 +139,9 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
if (invalidPrefixes.isNotEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))),
|
||||
content: Text(
|
||||
l10n.path_invalidHexPrefixes(invalidPrefixes.join(", ")),
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
|
|
@ -180,7 +189,10 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
children: [
|
||||
Text(
|
||||
l10n.path_currentPathLabel,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.onRefresh != null)
|
||||
|
|
@ -225,7 +237,10 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
children: [
|
||||
Text(
|
||||
l10n.path_selectFromContacts,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_selectedContacts.isNotEmpty)
|
||||
|
|
@ -242,7 +257,11 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 48, color: Colors.grey),
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.path_noRepeatersFound,
|
||||
|
|
@ -252,7 +271,10 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.path_customPathsRequire,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
|
@ -275,20 +297,30 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
radius: 16,
|
||||
backgroundColor: isSelected
|
||||
? Colors.green
|
||||
: (contact.type == 2 ? Colors.blue : Colors.purple),
|
||||
: (contact.type == 2
|
||||
? Colors.blue
|
||||
: Colors.purple),
|
||||
child: Icon(
|
||||
contact.type == 2 ? Icons.router : Icons.meeting_room,
|
||||
contact.type == 2
|
||||
? Icons.router
|
||||
: Icons.meeting_room,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
|
||||
title: Text(
|
||||
contact.name,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${contact.typeLabel} • ${contact.publicKeyHex.substring(0, 2)}',
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
? const Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.green,
|
||||
)
|
||||
: const Icon(Icons.add_circle_outline),
|
||||
onTap: () => _toggleContact(contact),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,13 +8,9 @@ import '../connector/meshcore_protocol.dart';
|
|||
import '../models/contact.dart';
|
||||
import '../widgets/snr_indicator.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
class PathTraceDialog extends StatefulWidget {
|
||||
|
||||
const PathTraceDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.path,
|
||||
});
|
||||
class PathTraceDialog extends StatefulWidget {
|
||||
const PathTraceDialog({super.key, required this.title, required this.path});
|
||||
|
||||
final String title;
|
||||
final Uint8List path;
|
||||
|
|
@ -31,7 +27,7 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
|
|||
bool _failed2Loaded = false;
|
||||
bool _hasData = false;
|
||||
Uint8List _pathData = Uint8List(0);
|
||||
Uint8List _snrData = Uint8List(0) ;
|
||||
Uint8List _snrData = Uint8List(0);
|
||||
Map<int, Contact> _pathContacts = {};
|
||||
|
||||
@override
|
||||
|
|
@ -49,13 +45,13 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
|
|||
}
|
||||
|
||||
Future<void> _doPathTrace() async {
|
||||
if(mounted) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_failed2Loaded = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final frame = buildTraceReq(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
|
|
@ -92,18 +88,19 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
|
|||
}
|
||||
|
||||
// Check if it's a binary response
|
||||
if (code == pushCodeTraceData && listEquals(frame.sublist(4, 8), tagData)) {
|
||||
if (code == pushCodeTraceData &&
|
||||
listEquals(frame.sublist(4, 8), tagData)) {
|
||||
_timeoutTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
frameBuffer.skipBytes(3); //reserved + path length + flag
|
||||
if(listEquals(frameBuffer.readBytes(4), tagData)){
|
||||
if (listEquals(frameBuffer.readBytes(4), tagData)) {
|
||||
_handleTraceResponse(frame);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleTraceResponse(Uint8List frame)async {
|
||||
Future<void> _handleTraceResponse(Uint8List frame) async {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
final buffer = BufferReader(frame);
|
||||
|
|
@ -116,9 +113,7 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
|
|||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
|
||||
connector.contacts.where((c) => c.type != advTypeChat).forEach((
|
||||
repeater,
|
||||
) {
|
||||
connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
for (var neighbourData in pathData) {
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, 1),
|
||||
|
|
@ -143,21 +138,26 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
|
|||
if (index == 0) {
|
||||
return context.l10n.pathTrace_you;
|
||||
} else {
|
||||
return _pathContacts[_pathData[_pathData.length - 1]]?.name ?? "0x${_pathData[_pathData.length - 1].toRadixString(16).toUpperCase()}";
|
||||
return _pathContacts[_pathData[_pathData.length - 1]]?.name ??
|
||||
"0x${_pathData[_pathData.length - 1].toRadixString(16).toUpperCase()}";
|
||||
}
|
||||
} else {
|
||||
return _pathContacts[_pathData[index-1]]?.name ?? "0x${_pathData[index-1].toRadixString(16).toUpperCase()}";
|
||||
return _pathContacts[_pathData[index - 1]]?.name ??
|
||||
"0x${_pathData[index - 1].toRadixString(16).toUpperCase()}";
|
||||
}
|
||||
}
|
||||
|
||||
String formatDirectionSubText(int index) {
|
||||
if (index == 0 || index == _snrData.length - 1) {
|
||||
if (index == 0) {
|
||||
return _pathContacts[_pathData[0]]?.name ?? "0x${_pathData[0].toRadixString(16).toUpperCase()}";
|
||||
return _pathContacts[_pathData[0]]?.name ??
|
||||
"0x${_pathData[0].toRadixString(16).toUpperCase()}";
|
||||
} else {
|
||||
return context.l10n.pathTrace_you;
|
||||
}
|
||||
} else {
|
||||
return _pathContacts[_pathData[index]]?.name ?? "0x${_pathData[index].toRadixString(16).toUpperCase()}";
|
||||
return _pathContacts[_pathData[index]]?.name ??
|
||||
"0x${_pathData[index].toRadixString(16).toUpperCase()}";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,47 +165,61 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
|
|||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return AlertDialog(
|
||||
title: Column( children: [
|
||||
FittedBox(fit: BoxFit.scaleDown, child: Text(widget.title, style: const TextStyle(fontSize: 24))),
|
||||
if(_failed2Loaded)
|
||||
Text(l10n.pathTrace_failed, style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.error),),
|
||||
title: Column(
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(widget.title, style: const TextStyle(fontSize: 24)),
|
||||
),
|
||||
if (_failed2Loaded)
|
||||
Text(
|
||||
l10n.pathTrace_failed,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _doPathTrace,
|
||||
child: !_hasData
|
||||
? Center(
|
||||
child: Text(l10n.pathTrace_notAvailable),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _snrData.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: index >= _snrData.length / 2 ? Icon(Icons.call_received) : Icon(Icons.call_made),
|
||||
title: Text(
|
||||
formatDirectionText(index), style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
formatDirectionSubText(index),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
trailing: SNRIcon(snr: _snrData[index].toSigned(8) / 4.0),
|
||||
onTap: () {
|
||||
// Handle item tap
|
||||
},
|
||||
? Center(child: Text(l10n.pathTrace_notAvailable))
|
||||
: ListView.builder(
|
||||
itemCount: _snrData.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: index >= _snrData.length / 2
|
||||
? Icon(Icons.call_received)
|
||||
: Icon(Icons.call_made),
|
||||
title: Text(
|
||||
formatDirectionText(index),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (index < _snrData.length - 1) const Divider(height: 0.0),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
subtitle: Text(
|
||||
formatDirectionSubText(index),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
trailing: SNRIcon(
|
||||
snr: _snrData[index].toSigned(8) / 4.0,
|
||||
),
|
||||
onTap: () {
|
||||
// Handle item tap
|
||||
},
|
||||
),
|
||||
if (index < _snrData.length - 1)
|
||||
const Divider(height: 0.0),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
|
|
|
|||
|
|
@ -121,10 +121,7 @@ class QrCodeDisplay extends StatelessWidget {
|
|||
size: size,
|
||||
backgroundColor: bgColor,
|
||||
errorCorrectionLevel: errorCorrectionLevel,
|
||||
eyeStyle: QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: fgColor,
|
||||
),
|
||||
eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.square, color: fgColor),
|
||||
dataModuleStyle: QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: fgColor,
|
||||
|
|
@ -143,10 +140,7 @@ class QrCodeDisplay extends StatelessWidget {
|
|||
backgroundColor: bgColor,
|
||||
// Use higher error correction when embedding image
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.H,
|
||||
eyeStyle: QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: fgColor,
|
||||
),
|
||||
eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.square, color: fgColor),
|
||||
dataModuleStyle: QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: fgColor,
|
||||
|
|
|
|||
|
|
@ -215,10 +215,7 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
|
|||
),
|
||||
child: Text(
|
||||
widget.instructions!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
|
@ -274,7 +271,8 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
|
|||
|
||||
switch (error.errorCode) {
|
||||
case MobileScannerErrorCode.permissionDenied:
|
||||
message = 'Camera permission denied.\nPlease enable camera access in settings.';
|
||||
message =
|
||||
'Camera permission denied.\nPlease enable camera access in settings.';
|
||||
icon = Icons.no_photography;
|
||||
break;
|
||||
case MobileScannerErrorCode.unsupported:
|
||||
|
|
@ -282,7 +280,8 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
|
|||
icon = Icons.videocam_off;
|
||||
break;
|
||||
default:
|
||||
message = 'Failed to start camera.\n${error.errorDetails?.message ?? ''}';
|
||||
message =
|
||||
'Failed to start camera.\n${error.errorDetails?.message ?? ''}';
|
||||
icon = Icons.error_outline;
|
||||
}
|
||||
|
||||
|
|
@ -297,10 +296,7 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
|
|||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 16,
|
||||
),
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
}
|
||||
|
||||
Future<void> _loadSavedPassword() async {
|
||||
final savedPassword =
|
||||
await _storage.getRepeaterPassword(widget.repeater.publicKeyHex);
|
||||
final savedPassword = await _storage.getRepeaterPassword(
|
||||
widget.repeater.publicKeyHex,
|
||||
);
|
||||
if (savedPassword != null) {
|
||||
setState(() {
|
||||
_passwordController.text = savedPassword;
|
||||
|
|
@ -102,12 +103,10 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
);
|
||||
final timeoutSeconds = (timeoutMs / 1000).ceil();
|
||||
final timeout = Duration(milliseconds: timeoutMs);
|
||||
final selectionLabel =
|
||||
selection.useFlood ? 'flood' : '${selection.hopCount} hops';
|
||||
appLogger.info(
|
||||
'Login routing: $selectionLabel',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
final selectionLabel = selection.useFlood
|
||||
? 'flood'
|
||||
: '${selection.hopCount} hops';
|
||||
appLogger.info('Login routing: $selectionLabel', tag: 'RepeaterLogin');
|
||||
bool? loginResult;
|
||||
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
|
||||
if (!mounted) return;
|
||||
|
|
@ -119,9 +118,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
'Sending login attempt ${attempt + 1}/$_maxAttempts',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
await _connector.sendFrame(
|
||||
loginFrame,
|
||||
);
|
||||
await _connector.sendFrame(loginFrame);
|
||||
|
||||
loginResult = await _awaitLoginResponse(timeout);
|
||||
if (loginResult == true) {
|
||||
|
|
@ -171,7 +168,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
// Save password if requested
|
||||
if (_savePassword) {
|
||||
await _storage.saveRepeaterPassword(
|
||||
widget.repeater.publicKeyHex, password);
|
||||
widget.repeater.publicKeyHex,
|
||||
password,
|
||||
);
|
||||
} else {
|
||||
// Remove saved password if user unchecked the box
|
||||
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex);
|
||||
|
|
@ -269,152 +268,183 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.login_repeaterDescription,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_loginError != null) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.error, size: 18, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_loginError!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.login_password,
|
||||
hintText: l10n.login_enterPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_loginError != null && mounted) {
|
||||
setState(() {
|
||||
_loginError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) => _handleLogin(),
|
||||
autofocus: !(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS) &&
|
||||
_passwordController.text.isEmpty,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CheckboxListTile(
|
||||
value: _savePassword,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_savePassword = value ?? false;
|
||||
});
|
||||
},
|
||||
title: Text(
|
||||
l10n.login_savePassword,
|
||||
Text(
|
||||
l10n.login_repeaterDescription,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.login_savePasswordSubtitle,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.login_routing,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: l10n.login_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.login_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_loginError != null) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.login_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_loginError!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
icon: const Icon(Icons.timeline, size: 18),
|
||||
label: Text(l10n.login_managePaths),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.login_password,
|
||||
hintText: l10n.login_enterPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_loginError != null && mounted) {
|
||||
setState(() {
|
||||
_loginError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) => _handleLogin(),
|
||||
autofocus:
|
||||
!(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS) &&
|
||||
_passwordController.text.isEmpty,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
CheckboxListTile(
|
||||
value: _savePassword,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_savePassword = value ?? false;
|
||||
});
|
||||
},
|
||||
title: Text(
|
||||
l10n.login_savePassword,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.login_savePasswordSubtitle,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.login_routing,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: l10n.login_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(
|
||||
repeater,
|
||||
pathLen: -1,
|
||||
);
|
||||
} else {
|
||||
await connector.setPathOverride(
|
||||
repeater,
|
||||
pathLen: null,
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.login_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.login_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
icon: const Icon(Icons.timeline, size: 18),
|
||||
label: Text(l10n.login_managePaths),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
|
|||
|
|
@ -15,11 +15,7 @@ class RoomLoginDialog extends StatefulWidget {
|
|||
final Contact room;
|
||||
final Function(String password) onLogin;
|
||||
|
||||
const RoomLoginDialog({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.onLogin,
|
||||
});
|
||||
const RoomLoginDialog({super.key, required this.room, required this.onLogin});
|
||||
|
||||
@override
|
||||
State<RoomLoginDialog> createState() => _RoomLoginDialogState();
|
||||
|
|
@ -43,8 +39,9 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
}
|
||||
|
||||
Future<void> _loadSavedPassword() async {
|
||||
final savedPassword =
|
||||
await _storage.getRepeaterPassword(widget.room.publicKeyHex);
|
||||
final savedPassword = await _storage.getRepeaterPassword(
|
||||
widget.room.publicKeyHex,
|
||||
);
|
||||
if (savedPassword != null) {
|
||||
setState(() {
|
||||
_passwordController.text = savedPassword;
|
||||
|
|
@ -100,12 +97,10 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
);
|
||||
final timeoutSeconds = (timeoutMs / 1000).ceil();
|
||||
final timeout = Duration(milliseconds: timeoutMs);
|
||||
final selectionLabel =
|
||||
selection.useFlood ? 'flood' : '${selection.hopCount} hops';
|
||||
appLogger.info(
|
||||
'Login routing: $selectionLabel',
|
||||
tag: 'RoomLogin',
|
||||
);
|
||||
final selectionLabel = selection.useFlood
|
||||
? 'flood'
|
||||
: '${selection.hopCount} hops';
|
||||
appLogger.info('Login routing: $selectionLabel', tag: 'RoomLogin');
|
||||
bool? loginResult;
|
||||
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
|
||||
if (!mounted) return;
|
||||
|
|
@ -117,23 +112,15 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
'Sending login attempt ${attempt + 1}/$_maxAttempts',
|
||||
tag: 'RoomLogin',
|
||||
);
|
||||
await _connector.sendFrame(
|
||||
loginFrame,
|
||||
);
|
||||
await _connector.sendFrame(loginFrame);
|
||||
|
||||
loginResult = await _awaitLoginResponse(timeout);
|
||||
if (loginResult == true) {
|
||||
appLogger.info(
|
||||
'Login succeeded for ${room.name}',
|
||||
tag: 'RoomLogin',
|
||||
);
|
||||
appLogger.info('Login succeeded for ${room.name}', tag: 'RoomLogin');
|
||||
break;
|
||||
}
|
||||
if (loginResult == false) {
|
||||
appLogger.warn(
|
||||
'Login failed for ${room.name}',
|
||||
tag: 'RoomLogin',
|
||||
);
|
||||
appLogger.warn('Login failed for ${room.name}', tag: 'RoomLogin');
|
||||
throw Exception('Wrong password or node is unreachable');
|
||||
}
|
||||
appLogger.warn(
|
||||
|
|
@ -143,10 +130,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
}
|
||||
|
||||
if (loginResult == null) {
|
||||
appLogger.warn(
|
||||
'Login timed out for ${room.name}',
|
||||
tag: 'RoomLogin',
|
||||
);
|
||||
appLogger.warn('Login timed out for ${room.name}', tag: 'RoomLogin');
|
||||
}
|
||||
|
||||
if (loginResult == true) {
|
||||
|
|
@ -162,8 +146,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
// If we got a response, login succeeded
|
||||
// Save password if requested
|
||||
if (_savePassword) {
|
||||
await _storage.saveRepeaterPassword(
|
||||
widget.room.publicKeyHex, password);
|
||||
await _storage.saveRepeaterPassword(widget.room.publicKeyHex, password);
|
||||
} else {
|
||||
// Remove saved password if user unchecked the box
|
||||
await _storage.removeRepeaterPassword(widget.room.publicKeyHex);
|
||||
|
|
@ -175,10 +158,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
}
|
||||
} catch (e) {
|
||||
final room = _resolveRepeater(_connector);
|
||||
appLogger.warn(
|
||||
'Login error for ${room.name}: $e',
|
||||
tag: 'RoomLogin',
|
||||
);
|
||||
appLogger.warn('Login error for ${room.name}: $e', tag: 'RoomLogin');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoggingIn = false;
|
||||
|
|
@ -262,130 +242,157 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.login_roomDescription,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.login_password,
|
||||
hintText: l10n.login_enterPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _handleLogin(),
|
||||
autofocus: !(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS) &&
|
||||
_passwordController.text.isEmpty,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CheckboxListTile(
|
||||
value: _savePassword,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_savePassword = value ?? false;
|
||||
});
|
||||
},
|
||||
title: Text(
|
||||
l10n.login_savePassword,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.login_roomDescription,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.login_savePasswordSubtitle,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.login_routing,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: l10n.login_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.login_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.login_password,
|
||||
hintText: l10n.login_enterPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.login_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
icon: const Icon(Icons.timeline, size: 18),
|
||||
label: Text(l10n.login_managePaths),
|
||||
onSubmitted: (_) => _handleLogin(),
|
||||
autofocus:
|
||||
!(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS) &&
|
||||
_passwordController.text.isEmpty,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
CheckboxListTile(
|
||||
value: _savePassword,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_savePassword = value ?? false;
|
||||
});
|
||||
},
|
||||
title: Text(
|
||||
l10n.login_savePassword,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.login_savePasswordSubtitle,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.login_routing,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: l10n.login_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(
|
||||
repeater,
|
||||
pathLen: -1,
|
||||
);
|
||||
} else {
|
||||
await connector.setPathOverride(
|
||||
repeater,
|
||||
pathLen: null,
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.login_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.login_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
icon: const Icon(Icons.timeline, size: 18),
|
||||
label: Text(l10n.login_managePaths),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import 'package:flutter/material.dart';
|
|||
class UnreadBadge extends StatelessWidget {
|
||||
final int count;
|
||||
|
||||
const UnreadBadge({
|
||||
super.key,
|
||||
required this.count,
|
||||
});
|
||||
const UnreadBadge({super.key, required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
|||
|
|
@ -7,30 +7,50 @@ void main() {
|
|||
group('reactionEmojis', () {
|
||||
test('should contain all emoji categories', () {
|
||||
final emojis = ReactionHelper.reactionEmojis;
|
||||
|
||||
|
||||
// Should contain quickEmojis
|
||||
for (final emoji in EmojiPicker.quickEmojis) {
|
||||
expect(emojis.contains(emoji), isTrue, reason: 'Missing quick emoji: $emoji');
|
||||
expect(
|
||||
emojis.contains(emoji),
|
||||
isTrue,
|
||||
reason: 'Missing quick emoji: $emoji',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Should contain smileys
|
||||
for (final emoji in EmojiPicker.smileys) {
|
||||
expect(emojis.contains(emoji), isTrue, reason: 'Missing smiley: $emoji');
|
||||
expect(
|
||||
emojis.contains(emoji),
|
||||
isTrue,
|
||||
reason: 'Missing smiley: $emoji',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Should contain gestures
|
||||
for (final emoji in EmojiPicker.gestures) {
|
||||
expect(emojis.contains(emoji), isTrue, reason: 'Missing gesture: $emoji');
|
||||
expect(
|
||||
emojis.contains(emoji),
|
||||
isTrue,
|
||||
reason: 'Missing gesture: $emoji',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Should contain hearts
|
||||
for (final emoji in EmojiPicker.hearts) {
|
||||
expect(emojis.contains(emoji), isTrue, reason: 'Missing heart: $emoji');
|
||||
expect(
|
||||
emojis.contains(emoji),
|
||||
isTrue,
|
||||
reason: 'Missing heart: $emoji',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Should contain objects
|
||||
for (final emoji in EmojiPicker.objects) {
|
||||
expect(emojis.contains(emoji), isTrue, reason: 'Missing object: $emoji');
|
||||
expect(
|
||||
emojis.contains(emoji),
|
||||
isTrue,
|
||||
reason: 'Missing object: $emoji',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -43,7 +63,7 @@ void main() {
|
|||
test('should return 2-char hex for valid emoji', () {
|
||||
// First emoji (thumbs up) should be index 0
|
||||
expect(ReactionHelper.emojiToIndex('👍'), equals('00'));
|
||||
|
||||
|
||||
// Second emoji (heart) should be index 1
|
||||
expect(ReactionHelper.emojiToIndex('❤️'), equals('01'));
|
||||
});
|
||||
|
|
@ -67,7 +87,10 @@ void main() {
|
|||
});
|
||||
|
||||
test('should return null for invalid index', () {
|
||||
expect(ReactionHelper.indexToEmoji('ff'), isNull); // Index 255, out of range
|
||||
expect(
|
||||
ReactionHelper.indexToEmoji('ff'),
|
||||
isNull,
|
||||
); // Index 255, out of range
|
||||
expect(ReactionHelper.indexToEmoji('zz'), isNull); // Invalid hex
|
||||
expect(ReactionHelper.indexToEmoji(''), isNull); // Empty string
|
||||
// Note: indexToEmoji parses any valid hex; length validation is done by parseReaction's regex
|
||||
|
|
@ -86,78 +109,158 @@ void main() {
|
|||
final emoji = ReactionHelper.reactionEmojis[i];
|
||||
final index = ReactionHelper.emojiToIndex(emoji);
|
||||
expect(index, isNotNull, reason: 'emojiToIndex failed for $emoji');
|
||||
|
||||
|
||||
final decoded = ReactionHelper.indexToEmoji(index!);
|
||||
expect(decoded, equals(emoji), reason: 'Round-trip failed for $emoji (index $index)');
|
||||
expect(
|
||||
decoded,
|
||||
equals(emoji),
|
||||
reason: 'Round-trip failed for $emoji (index $index)',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('computeReactionHash', () {
|
||||
test('should return 4-char hex hash', () {
|
||||
final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello world');
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'Hello world',
|
||||
);
|
||||
expect(hash, matches(RegExp(r'^[0-9a-f]{4}$')));
|
||||
});
|
||||
|
||||
test('should be deterministic', () {
|
||||
final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello');
|
||||
final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello');
|
||||
final hash1 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'Hello',
|
||||
);
|
||||
final hash2 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'Hello',
|
||||
);
|
||||
expect(hash1, equals(hash2));
|
||||
});
|
||||
|
||||
test('should differ for different inputs', () {
|
||||
final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello');
|
||||
final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Bob', 'Hello');
|
||||
final hash3 = ReactionHelper.computeReactionHash(1234567891, 'Alice', 'Hello');
|
||||
final hash4 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'World');
|
||||
|
||||
final hash1 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'Hello',
|
||||
);
|
||||
final hash2 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Bob',
|
||||
'Hello',
|
||||
);
|
||||
final hash3 = ReactionHelper.computeReactionHash(
|
||||
1234567891,
|
||||
'Alice',
|
||||
'Hello',
|
||||
);
|
||||
final hash4 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'World',
|
||||
);
|
||||
|
||||
expect(hash1, isNot(equals(hash2))); // Different sender
|
||||
expect(hash1, isNot(equals(hash3))); // Different timestamp
|
||||
expect(hash1, isNot(equals(hash4))); // Different text
|
||||
});
|
||||
|
||||
test('should use first 5 chars of text', () {
|
||||
final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello world');
|
||||
final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello there');
|
||||
final hash1 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'Hello world',
|
||||
);
|
||||
final hash2 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'Hello there',
|
||||
);
|
||||
expect(hash1, equals(hash2)); // Same first 5 chars
|
||||
});
|
||||
|
||||
test('should handle short text', () {
|
||||
final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hi');
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'Hi',
|
||||
);
|
||||
expect(hash, matches(RegExp(r'^[0-9a-f]{4}$')));
|
||||
});
|
||||
|
||||
test('should handle empty text', () {
|
||||
final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', '');
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'',
|
||||
);
|
||||
expect(hash, matches(RegExp(r'^[0-9a-f]{4}$')));
|
||||
});
|
||||
});
|
||||
|
||||
group('computeReactionHash with null sender (1:1 chats)', () {
|
||||
test('should return 4-char hex hash', () {
|
||||
final hash = ReactionHelper.computeReactionHash(1234567890, null, 'Hello world');
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
null,
|
||||
'Hello world',
|
||||
);
|
||||
expect(hash, matches(RegExp(r'^[0-9a-f]{4}$')));
|
||||
});
|
||||
|
||||
test('should be deterministic', () {
|
||||
final hash1 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello');
|
||||
final hash2 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello');
|
||||
final hash1 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
null,
|
||||
'Hello',
|
||||
);
|
||||
final hash2 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
null,
|
||||
'Hello',
|
||||
);
|
||||
expect(hash1, equals(hash2));
|
||||
});
|
||||
|
||||
test('should differ for different inputs', () {
|
||||
final hash1 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello');
|
||||
final hash2 = ReactionHelper.computeReactionHash(1234567891, null, 'Hello');
|
||||
final hash3 = ReactionHelper.computeReactionHash(1234567890, null, 'World');
|
||||
|
||||
final hash1 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
null,
|
||||
'Hello',
|
||||
);
|
||||
final hash2 = ReactionHelper.computeReactionHash(
|
||||
1234567891,
|
||||
null,
|
||||
'Hello',
|
||||
);
|
||||
final hash3 = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
null,
|
||||
'World',
|
||||
);
|
||||
|
||||
expect(hash1, isNot(equals(hash2))); // Different timestamp
|
||||
expect(hash1, isNot(equals(hash3))); // Different text
|
||||
});
|
||||
|
||||
test('should differ from hash with sender name', () {
|
||||
// Null sender hash doesn't include sender, so should differ
|
||||
final nullSenderHash = ReactionHelper.computeReactionHash(1234567890, null, 'Hello');
|
||||
final withSenderHash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello');
|
||||
final nullSenderHash = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
null,
|
||||
'Hello',
|
||||
);
|
||||
final withSenderHash = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'Hello',
|
||||
);
|
||||
expect(nullSenderHash, isNot(equals(withSenderHash)));
|
||||
});
|
||||
|
||||
|
|
@ -167,13 +270,21 @@ void main() {
|
|||
// Bob computes hash the same way Alice's app will match it
|
||||
const timestamp = 1234567890;
|
||||
const messageText = 'Hello there!';
|
||||
|
||||
|
||||
// Bob (sender of reaction) computes hash with null sender
|
||||
final bobHash = ReactionHelper.computeReactionHash(timestamp, null, messageText);
|
||||
|
||||
final bobHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
null,
|
||||
messageText,
|
||||
);
|
||||
|
||||
// Alice (receiver of reaction) computes hash for her outgoing message
|
||||
final aliceHash = ReactionHelper.computeReactionHash(timestamp, null, messageText);
|
||||
|
||||
final aliceHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
null,
|
||||
messageText,
|
||||
);
|
||||
|
||||
expect(bobHash, equals(aliceHash));
|
||||
});
|
||||
});
|
||||
|
|
@ -188,12 +299,30 @@ void main() {
|
|||
|
||||
test('should return null for invalid format', () {
|
||||
expect(ReactionHelper.parseReaction('invalid'), isNull);
|
||||
expect(ReactionHelper.parseReaction('r:abc:00'), isNull); // Hash too short
|
||||
expect(ReactionHelper.parseReaction('r:abcde:00'), isNull); // Hash too long
|
||||
expect(ReactionHelper.parseReaction('r:a1b2:0'), isNull); // Index too short
|
||||
expect(ReactionHelper.parseReaction('r:a1b2:000'), isNull); // Index too long
|
||||
expect(ReactionHelper.parseReaction('R:a1b2:00'), isNull); // Uppercase R
|
||||
expect(ReactionHelper.parseReaction('r:A1B2:00'), isNull); // Uppercase hash
|
||||
expect(
|
||||
ReactionHelper.parseReaction('r:abc:00'),
|
||||
isNull,
|
||||
); // Hash too short
|
||||
expect(
|
||||
ReactionHelper.parseReaction('r:abcde:00'),
|
||||
isNull,
|
||||
); // Hash too long
|
||||
expect(
|
||||
ReactionHelper.parseReaction('r:a1b2:0'),
|
||||
isNull,
|
||||
); // Index too short
|
||||
expect(
|
||||
ReactionHelper.parseReaction('r:a1b2:000'),
|
||||
isNull,
|
||||
); // Index too long
|
||||
expect(
|
||||
ReactionHelper.parseReaction('R:a1b2:00'),
|
||||
isNull,
|
||||
); // Uppercase R
|
||||
expect(
|
||||
ReactionHelper.parseReaction('r:A1B2:00'),
|
||||
isNull,
|
||||
); // Uppercase hash
|
||||
expect(ReactionHelper.parseReaction(''), isNull);
|
||||
});
|
||||
|
||||
|
|
@ -220,31 +349,43 @@ void main() {
|
|||
const emoji = '🎉';
|
||||
|
||||
// Compute hash (sender side)
|
||||
final hash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText);
|
||||
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
senderName,
|
||||
messageText,
|
||||
);
|
||||
|
||||
// Encode emoji (sender side)
|
||||
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
||||
expect(emojiIndex, isNotNull);
|
||||
|
||||
|
||||
// Build reaction text (sender side)
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
|
||||
|
||||
// Parse reaction (receiver side)
|
||||
final info = ReactionHelper.parseReaction(reactionText);
|
||||
expect(info, isNotNull);
|
||||
expect(info!.targetHash, equals(hash));
|
||||
expect(info.emoji, equals(emoji));
|
||||
|
||||
|
||||
// Verify receiver can match the hash
|
||||
final receiverHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText);
|
||||
final receiverHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
senderName,
|
||||
messageText,
|
||||
);
|
||||
expect(receiverHash, equals(info.targetHash));
|
||||
});
|
||||
|
||||
test('reaction text should be 9 bytes', () {
|
||||
final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello');
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
1234567890,
|
||||
'Alice',
|
||||
'Hello',
|
||||
);
|
||||
final index = ReactionHelper.emojiToIndex('👍')!;
|
||||
final reactionText = 'r:$hash:$index';
|
||||
|
||||
|
||||
// r: (2) + hash (4) + : (1) + index (2) = 9 bytes
|
||||
expect(reactionText.length, equals(9));
|
||||
});
|
||||
|
|
@ -257,7 +398,11 @@ void main() {
|
|||
const emoji = '👍';
|
||||
|
||||
// On Bob's device: message.isOutgoing = false, so senderName = contact.name = Alice
|
||||
final bobSideHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText);
|
||||
final bobSideHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
aliceName,
|
||||
messageText,
|
||||
);
|
||||
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
|
||||
final reactionText = 'r:$bobSideHash:$emojiIndex';
|
||||
|
||||
|
|
@ -266,8 +411,12 @@ void main() {
|
|||
expect(info, isNotNull);
|
||||
|
||||
// On Alice's device: message.isOutgoing = true, so senderName = selfName = Alice
|
||||
final aliceSideHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText);
|
||||
|
||||
final aliceSideHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
aliceName,
|
||||
messageText,
|
||||
);
|
||||
|
||||
// Hashes should match!
|
||||
expect(info!.targetHash, equals(aliceSideHash));
|
||||
expect(info.emoji, equals(emoji));
|
||||
|
|
@ -281,7 +430,11 @@ void main() {
|
|||
const emoji = '❤️';
|
||||
|
||||
// On Alice's device: message.isOutgoing = false, so senderName = contact.name = Bob
|
||||
final aliceSideHash = ReactionHelper.computeReactionHash(timestamp, bobName, messageText);
|
||||
final aliceSideHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
bobName,
|
||||
messageText,
|
||||
);
|
||||
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
|
||||
final reactionText = 'r:$aliceSideHash:$emojiIndex';
|
||||
|
||||
|
|
@ -290,8 +443,12 @@ void main() {
|
|||
expect(info, isNotNull);
|
||||
|
||||
// On Bob's device: message.isOutgoing = true, so senderName = selfName = Bob
|
||||
final bobSideHash = ReactionHelper.computeReactionHash(timestamp, bobName, messageText);
|
||||
|
||||
final bobSideHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
bobName,
|
||||
messageText,
|
||||
);
|
||||
|
||||
// Hashes should match!
|
||||
expect(info!.targetHash, equals(bobSideHash));
|
||||
expect(info.emoji, equals(emoji));
|
||||
|
|
@ -306,7 +463,11 @@ void main() {
|
|||
const emoji = '🎉';
|
||||
|
||||
// Alice computes hash including sender name (room servers are multi-user)
|
||||
final aliceHash = ReactionHelper.computeReactionHash(timestamp, charlieName, messageText);
|
||||
final aliceHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
charlieName,
|
||||
messageText,
|
||||
);
|
||||
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
|
||||
final reactionText = 'r:$aliceHash:$emojiIndex';
|
||||
|
||||
|
|
@ -319,36 +480,59 @@ void main() {
|
|||
expect(info, isNotNull);
|
||||
|
||||
// Bob computes hash for Charlie's message the same way
|
||||
final bobHash = ReactionHelper.computeReactionHash(timestamp, charlieName, messageText);
|
||||
|
||||
final bobHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
charlieName,
|
||||
messageText,
|
||||
);
|
||||
|
||||
// Hashes should match!
|
||||
expect(info!.targetHash, equals(bobHash));
|
||||
expect(info.emoji, equals(emoji));
|
||||
});
|
||||
|
||||
test('room server: hash differs from 1:1 hash for same message content', () {
|
||||
// Same timestamp and text, but room server includes sender name
|
||||
const timestamp = 1234567890;
|
||||
const senderName = 'Dave';
|
||||
const messageText = 'Hello';
|
||||
test(
|
||||
'room server: hash differs from 1:1 hash for same message content',
|
||||
() {
|
||||
// Same timestamp and text, but room server includes sender name
|
||||
const timestamp = 1234567890;
|
||||
const senderName = 'Dave';
|
||||
const messageText = 'Hello';
|
||||
|
||||
// Room server hash (with sender name)
|
||||
final roomHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText);
|
||||
|
||||
// 1:1 hash (without sender name)
|
||||
final directHash = ReactionHelper.computeReactionHash(timestamp, null, messageText);
|
||||
// Room server hash (with sender name)
|
||||
final roomHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
senderName,
|
||||
messageText,
|
||||
);
|
||||
|
||||
// They should be different!
|
||||
expect(roomHash, isNot(equals(directHash)));
|
||||
});
|
||||
// 1:1 hash (without sender name)
|
||||
final directHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
null,
|
||||
messageText,
|
||||
);
|
||||
|
||||
// They should be different!
|
||||
expect(roomHash, isNot(equals(directHash)));
|
||||
},
|
||||
);
|
||||
|
||||
test('room server: different senders produce different hashes', () {
|
||||
// Two users send the exact same message at the same time in a room
|
||||
const timestamp = 1234567890;
|
||||
const messageText = 'Hello';
|
||||
|
||||
final aliceHash = ReactionHelper.computeReactionHash(timestamp, 'Alice', messageText);
|
||||
final bobHash = ReactionHelper.computeReactionHash(timestamp, 'Bob', messageText);
|
||||
final aliceHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
'Alice',
|
||||
messageText,
|
||||
);
|
||||
final bobHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
'Bob',
|
||||
messageText,
|
||||
);
|
||||
|
||||
// Different senders = different hashes (even with same content)
|
||||
expect(aliceHash, isNot(equals(bobHash)));
|
||||
|
|
@ -363,7 +547,11 @@ void main() {
|
|||
const emoji = '👍';
|
||||
|
||||
// Bob computes hash for Alice's message
|
||||
final bobHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText);
|
||||
final bobHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
aliceName,
|
||||
messageText,
|
||||
);
|
||||
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
|
||||
final reactionText = 'r:$bobHash:$emojiIndex';
|
||||
|
||||
|
|
@ -372,8 +560,12 @@ void main() {
|
|||
expect(info, isNotNull);
|
||||
|
||||
// Alice computes hash using her selfName
|
||||
final aliceHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText);
|
||||
|
||||
final aliceHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
aliceName,
|
||||
messageText,
|
||||
);
|
||||
|
||||
// Hashes should match!
|
||||
expect(info!.targetHash, equals(aliceHash));
|
||||
});
|
||||
|
|
@ -386,7 +578,11 @@ void main() {
|
|||
const emoji = '🔥';
|
||||
|
||||
// Compute hash with sender name
|
||||
final hash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText);
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
senderName,
|
||||
messageText,
|
||||
);
|
||||
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
|
||||
|
|
@ -396,7 +592,11 @@ void main() {
|
|||
expect(info!.emoji, equals(emoji));
|
||||
|
||||
// Another user computes the same hash
|
||||
final otherUserHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText);
|
||||
final otherUserHash = ReactionHelper.computeReactionHash(
|
||||
timestamp,
|
||||
senderName,
|
||||
messageText,
|
||||
);
|
||||
expect(info.targetHash, equals(otherUserHash));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue