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