Initial commit: MeshCore Open Flutter client

Open-source Flutter client for MeshCore LoRa mesh networking devices.

Features:
- BLE device scanning and connection
- Nordic UART Service (NUS) integration
- Material 3 design with system theme support
- Provider-based state management
- Placeholder screens for chat, contacts, and settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
zach
2025-12-26 11:42:02 -07:00
commit e7a5b9e209
177 changed files with 20129 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+534
View File
@@ -0,0 +1,534 @@
import 'dart:convert';
import 'dart:typed_data';
// Command codes (to device)
const int cmdAppStart = 1;
const int cmdSendTxtMsg = 2;
const int cmdSendChannelTxtMsg = 3;
const int cmdGetContacts = 4;
const int cmdGetDeviceTime = 5;
const int cmdSetDeviceTime = 6;
const int cmdSendSelfAdvert = 7;
const int cmdSetAdvertName = 8;
const int cmdAddUpdateContact = 9;
const int cmdSyncNextMessage = 10;
const int cmdSetRadioParams = 11;
const int cmdSetRadioTxPower = 12;
const int cmdResetPath = 13;
const int cmdSetAdvertLatLon = 14;
const int cmdRemoveContact = 15;
const int cmdShareContact = 16;
const int cmdExportContact = 17;
const int cmdImportContact = 18;
const int cmdReboot = 19;
const int cmdGetBattAndStorage = 20;
const int cmdDeviceQuery = 22;
const int cmdSendLogin = 26;
const int cmdSendStatusReq = 27;
const int cmdGetChannel = 31;
const int cmdSetChannel = 32;
const int cmdGetRadioSettings = 57;
// Text message types
const int txtTypePlain = 0;
const int txtTypeCliData = 1;
// Repeater request types (for server requests)
const int reqTypeGetStatus = 0x01;
const int reqTypeKeepAlive = 0x02;
const int reqTypeGetTelemetry = 0x03;
const int reqTypeGetAccessList = 0x05;
const int reqTypeGetNeighbours = 0x06;
// Repeater response codes
const int respServerLoginOk = 0;
// Response codes (from device)
const int respCodeOk = 0;
const int respCodeErr = 1;
const int respCodeContactsStart = 2;
const int respCodeContact = 3;
const int respCodeEndOfContacts = 4;
const int respCodeSelfInfo = 5;
const int respCodeSent = 6;
const int respCodeContactMsgRecv = 7;
const int respCodeChannelMsgRecv = 8;
const int respCodeCurrTime = 9;
const int respCodeNoMoreMessages = 10;
const int respCodeBattAndStorage = 12;
const int respCodeDeviceInfo = 13;
const int respCodeContactMsgRecvV3 = 16;
const int respCodeChannelMsgRecvV3 = 17;
const int respCodeChannelInfo = 18;
const int respCodeRadioSettings = 25;
// Push codes (async from device)
const int pushCodeAdvert = 0x80;
const int pushCodePathUpdated = 0x81;
const int pushCodeSendConfirmed = 0x82;
const int pushCodeMsgWaiting = 0x83;
const int pushCodeLoginSuccess = 0x85;
const int pushCodeLoginFail = 0x86;
const int pushCodeStatusResponse = 0x87;
const int pushCodeLogRxData = 0x88;
const int pushCodeNewAdvert = 0x8A;
// Contact/advertisement types
const int advTypeChat = 1;
const int advTypeRepeater = 2;
const int advTypeRoom = 3;
const int advTypeSensor = 4;
// Sizes
const int pubKeySize = 32;
const int maxPathSize = 64;
const int pathHashSize = 1;
const int maxNameSize = 32;
const int maxFrameSize = 172;
const int appProtocolVersion = 3;
// Contact frame offsets
const int contactPubKeyOffset = 1;
const int contactTypeOffset = 33;
const int contactFlagsOffset = 34;
const int contactPathLenOffset = 35;
const int contactPathOffset = 36;
const int contactNameOffset = 100;
const int contactTimestampOffset = 132;
const int contactLatOffset = 136;
const int contactLonOffset = 140;
const int contactLastmodOffset = 144;
const int contactFrameSize = 148;
// Message frame offsets
const int msgPubKeyOffset = 1;
const int msgTimestampOffset = 33;
const int msgFlagsOffset = 37;
const int msgTextOffset = 38;
class ParsedContactText {
final Uint8List senderPrefix;
final String text;
const ParsedContactText({
required this.senderPrefix,
required this.text,
});
}
ParsedContactText? parseContactMessageText(Uint8List frame) {
if (frame.isEmpty) return null;
final code = frame[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
// Companion radio layout:
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
final isV3 = code == respCodeContactMsgRecvV3;
final prefixOffset = isV3 ? 4 : 1;
const prefixLen = 6;
final txtTypeOffset = prefixOffset + prefixLen + 1;
final timestampOffset = txtTypeOffset + 1;
final baseTextOffset = timestampOffset + 4;
if (frame.length <= baseTextOffset) return null;
final flags = frame[txtTypeOffset];
final shiftedType = flags >> 2;
final rawType = flags;
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
if (!isPlain && !isCli) {
return null;
}
var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset).trim();
if (text.isEmpty && frame.length > baseTextOffset + 4) {
text =
readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim();
}
if (text.isEmpty) return null;
final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
return ParsedContactText(senderPrefix: senderPrefix, text: text);
}
// Helper to read uint32 little-endian
int readUint32LE(Uint8List data, int offset) {
return data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24);
}
// Helper to read uint16 little-endian
int readUint16LE(Uint8List data, int offset) {
return data[offset] | (data[offset + 1] << 8);
}
// Helper to read int32 little-endian
int readInt32LE(Uint8List data, int offset) {
int val = readUint32LE(data, offset);
if (val >= 0x80000000) val -= 0x100000000;
return val;
}
// Helper to write uint32 little-endian
void writeUint32LE(Uint8List data, int offset, int value) {
data[offset] = value & 0xFF;
data[offset + 1] = (value >> 8) & 0xFF;
data[offset + 2] = (value >> 16) & 0xFF;
data[offset + 3] = (value >> 24) & 0xFF;
}
// Helper to read null-terminated UTF-8 string
String readCString(Uint8List data, int offset, int maxLen) {
int end = offset;
while (end < offset + maxLen && end < data.length && data[end] != 0) {
end++;
}
try {
return utf8.decode(data.sublist(offset, end), allowMalformed: true);
} catch (e) {
// Fallback to Latin-1 if UTF-8 decoding fails
return String.fromCharCodes(data.sublist(offset, end));
}
}
// Helper to convert public key to hex string
String pubKeyToHex(Uint8List pubKey) {
return pubKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
// Helper to convert hex string to public key
Uint8List hexToPubKey(String hex) {
final result = Uint8List(pubKeySize);
for (int i = 0; i < pubKeySize && i * 2 + 1 < hex.length; i++) {
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return result;
}
// Build CMD_GET_CONTACTS frame
Uint8List buildGetContactsFrame({int? since}) {
if (since != null) {
final frame = Uint8List(5);
frame[0] = cmdGetContacts;
writeUint32LE(frame, 1, since);
return frame;
}
return Uint8List.fromList([cmdGetContacts]);
}
// Build CMD_SEND_LOGIN frame
// Format: [cmd][pub_key x32][password...]\0
Uint8List buildSendLoginFrame(Uint8List recipientPubKey, String password) {
final passwordBytes = utf8.encode(password);
final frame = Uint8List(1 + pubKeySize + passwordBytes.length + 1);
frame[0] = cmdSendLogin;
frame.setRange(1, 1 + pubKeySize, recipientPubKey);
frame.setRange(1 + pubKeySize, 1 + pubKeySize + passwordBytes.length, passwordBytes);
frame[frame.length - 1] = 0;
return frame;
}
// Build CMD_SEND_STATUS_REQ frame
// Format: [cmd][pub_key x32]
Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) {
final frame = Uint8List(1 + pubKeySize);
frame[0] = cmdSendStatusReq;
frame.setRange(1, 1 + pubKeySize, recipientPubKey);
return frame;
}
// Build CMD_SEND_TXT_MSG frame (companion_radio format)
// Format: [cmd][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]\0
Uint8List buildSendTextMsgFrame(
Uint8List recipientPubKey,
String text, {
bool forceFlood = false,
int attempt = 0,
int? timestampSeconds,
}) {
final textBytes = utf8.encode(text);
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
const prefixSize = 6;
final safeAttempt = forceFlood ? 3 : (attempt & 0xFF);
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
int offset = 0;
frame[offset++] = cmdSendTxtMsg;
frame[offset++] = txtTypePlain;
frame[offset++] = safeAttempt;
writeUint32LE(frame, offset, timestamp);
offset += 4;
frame.setRange(offset, offset + prefixSize, recipientPubKey.sublist(0, prefixSize));
offset += prefixSize;
frame.setRange(offset, offset + textBytes.length, textBytes);
frame[frame.length - 1] = 0; // null terminator
return frame;
}
// Build CMD_SEND_CHANNEL_TXT_MSG frame
// Format: [cmd][txt_type][channel_idx][timestamp x4][text...]
Uint8List buildSendChannelTextMsgFrame(int channelIndex, String text) {
final textBytes = utf8.encode(text);
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final frame = Uint8List(1 + 1 + 1 + 4 + textBytes.length + 1);
frame[0] = cmdSendChannelTxtMsg;
frame[1] = 0; // TXT_TYPE_PLAIN
frame[2] = channelIndex;
writeUint32LE(frame, 3, timestamp);
frame.setRange(7, 7 + textBytes.length, textBytes);
frame[frame.length - 1] = 0; // null terminator
return frame;
}
// Build CMD_REMOVE_CONTACT frame
Uint8List buildRemoveContactFrame(Uint8List pubKey) {
final frame = Uint8List(1 + pubKeySize);
frame[0] = cmdRemoveContact;
frame.setRange(1, 1 + pubKeySize, pubKey);
return frame;
}
// Build CMD_APP_START frame
// Format: [cmd][reserved x7][app_name...]
Uint8List buildAppStartFrame({String appName = 'MeshCoreOpen'}) {
final nameBytes = utf8.encode(appName);
final frame = Uint8List(8 + nameBytes.length + 1);
frame[0] = cmdAppStart;
// bytes 1-7 are reserved (zeros)
frame.setRange(8, 8 + nameBytes.length, nameBytes);
frame[frame.length - 1] = 0; // null terminator
return frame;
}
// Build CMD_DEVICE_QUERY frame
Uint8List buildDeviceQueryFrame({int appVersion = appProtocolVersion}) {
return Uint8List.fromList([cmdDeviceQuery, appVersion]);
}
// Build CMD_GET_DEVICE_TIME frame
Uint8List buildGetDeviceTimeFrame() {
return Uint8List.fromList([cmdGetDeviceTime]);
}
// Build CMD_GET_BATT_AND_STORAGE frame
Uint8List buildGetBattAndStorageFrame() {
return Uint8List.fromList([cmdGetBattAndStorage]);
}
// Build CMD_SET_DEVICE_TIME frame
Uint8List buildSetDeviceTimeFrame(int timestamp) {
final frame = Uint8List(5);
frame[0] = cmdSetDeviceTime;
writeUint32LE(frame, 1, timestamp);
return frame;
}
// Build CMD_SYNC_NEXT_MESSAGE frame
Uint8List buildSyncNextMessageFrame() {
return Uint8List.fromList([cmdSyncNextMessage]);
}
// Build CMD_GET_CHANNEL frame
Uint8List buildGetChannelFrame(int channelIndex) {
return Uint8List.fromList([cmdGetChannel, channelIndex]);
}
// Build CMD_SET_CHANNEL frame
// Format: [cmd][idx][name x32][psk x16]
Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
final frame = Uint8List(2 + 32 + 16);
frame[0] = cmdSetChannel;
frame[1] = channelIndex;
// Write name (max 32 bytes UTF-8, null-padded)
final nameBytes = utf8.encode(name);
final nameLen = nameBytes.length < 32 ? nameBytes.length : 31; // Reserve 1 byte for null
for (int i = 0; i < nameLen; i++) {
frame[2 + i] = nameBytes[i];
}
// frame[2 + nameLen] is already 0 (null terminator)
// Write PSK (16 bytes)
for (int i = 0; i < 16 && i < psk.length; i++) {
frame[34 + i] = psk[i];
}
return frame;
}
// Build CMD_SET_RADIO_PARAMS frame
// Format: [cmd][freq x4][bw x4][sf][cr]
// freq: frequency in Hz (300000-2500000)
// bw: bandwidth in Hz (7000-500000)
// sf: spreading factor (5-12)
// cr: coding rate (5-8)
Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) {
final frame = Uint8List(11);
frame[0] = cmdSetRadioParams;
writeUint32LE(frame, 1, freqHz);
writeUint32LE(frame, 5, bwHz);
frame[9] = sf;
frame[10] = cr;
return frame;
}
// Build CMD_SET_RADIO_TX_POWER frame
// Format: [cmd][power_dbm]
Uint8List buildSetRadioTxPowerFrame(int powerDbm) {
return Uint8List.fromList([cmdSetRadioTxPower, powerDbm]);
}
// Build CMD_RESET_PATH frame
// Format: [cmd][pub_key x32]
Uint8List buildResetPathFrame(Uint8List pubKey) {
final frame = Uint8List(1 + pubKeySize);
frame[0] = cmdResetPath;
frame.setRange(1, 1 + pubKeySize, pubKey);
return frame;
}
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
Uint8List buildUpdateContactPathFrame(
Uint8List pubKey,
Uint8List customPath,
int pathLen, {
int type = 1, // ADV_TYPE_CHAT
int flags = 0,
String name = '',
}) {
// Frame size: 1 + 32 + 1 + 1 + 1 + 64 + 32 + 4 = 136 bytes minimum
final frame = Uint8List(1 + pubKeySize + 1 + 1 + 1 + maxPathSize + maxNameSize + 4);
int offset = 0;
frame[offset++] = cmdAddUpdateContact;
// Public key (32 bytes)
frame.setRange(offset, offset + pubKeySize, pubKey);
offset += pubKeySize;
// Type and flags
frame[offset++] = type;
frame[offset++] = flags;
// Path length and path data
frame[offset++] = pathLen;
if (customPath.isNotEmpty && pathLen > 0) {
final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize;
frame.setRange(offset, offset + copyLen, customPath.sublist(0, copyLen));
}
offset += maxPathSize;
// Name (32 bytes, null-padded)
if (name.isNotEmpty) {
final nameBytes = utf8.encode(name);
final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1;
frame.setRange(offset, offset + nameLen, nameBytes.sublist(0, nameLen));
}
offset += maxNameSize;
// Timestamp (current time)
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
writeUint32LE(frame, offset, timestamp);
return frame;
}
// Build CMD_GET_RADIO_SETTINGS frame
Uint8List buildGetRadioSettingsFrame() {
return Uint8List.fromList([cmdGetRadioSettings]);
}
// Calculate LoRa airtime for a packet
// Based on Semtech SX127x datasheet formula
// Returns airtime in milliseconds
int calculateLoRaAirtime({
required int payloadBytes,
required int spreadingFactor,
required int bandwidthHz,
required int codingRate,
int preambleSymbols = 8,
bool lowDataRateOptimize = false,
bool explicitHeader = true,
}) {
// Symbol duration (Ts) in milliseconds
final symbolDuration = (1 << spreadingFactor) / (bandwidthHz / 1000.0);
// Preamble time
final preambleTime = (preambleSymbols + 4.25) * symbolDuration;
// Payload symbol count
final headerBytes = explicitHeader ? 0 : 20;
final crc = 1; // CRC enabled
final de = lowDataRateOptimize ? 1 : 0;
final numerator = 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
final denominator = 4 * (spreadingFactor - 2 * de);
var payloadSymbols = 8 + ((numerator / denominator).ceil()) * (codingRate + 4);
if (payloadSymbols < 0) {
payloadSymbols = 8;
}
final payloadTime = payloadSymbols * symbolDuration;
return (preambleTime + payloadTime).ceil();
}
// Calculate timeout for a message based on radio settings
// Returns timeout in milliseconds
int calculateMessageTimeout({
required int freqHz,
required int bwHz,
required int sf,
required int cr,
required int pathLength,
int messageBytes = 100, // Average message size
}) {
// Calculate airtime for one packet
final airtime = calculateLoRaAirtime(
payloadBytes: messageBytes,
spreadingFactor: sf,
bandwidthHz: bwHz,
codingRate: cr,
lowDataRateOptimize: sf >= 11,
);
if (pathLength < 0) {
// Flood mode: Base delay + 16× airtime
return 500 + (16 * airtime);
} else {
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1))
return 500 + ((airtime * 6 + 250) * (pathLength + 1));
}
}
// Build CLI command text message frame (companion_radio format)
// Format: [cmd][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]\0
Uint8List buildSendCliCommandFrame(
Uint8List repeaterPubKey,
String command, {
int attempt = 0,
}) {
final textBytes = utf8.encode(command);
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
const prefixSize = 6;
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
int offset = 0;
frame[offset++] = cmdSendTxtMsg;
frame[offset++] = txtTypeCliData;
frame[offset++] = attempt & 0xFF;
writeUint32LE(frame, offset, timestamp);
offset += 4;
frame.setRange(offset, offset + prefixSize, repeaterPubKey.sublist(0, prefixSize));
offset += prefixSize;
frame.setRange(offset, offset + textBytes.length, textBytes);
frame[frame.length - 1] = 0; // null terminator
return frame;
}
+411
View File
@@ -0,0 +1,411 @@
import 'dart:convert';
import 'dart:typed_data';
class Smaz {
static const int _verbatimSingle = 254;
static const int _verbatimRun = 255;
static const List<String> _rcb = [
" ",
"the",
"e",
"t",
"a",
"of",
"o",
"and",
"i",
"n",
"s",
"e ",
"r",
" th",
" t",
"in",
"he",
"th",
"h",
"he ",
"to",
"\r\n",
"l",
"s ",
"d",
" a",
"an",
"er",
"c",
" o",
"d ",
"on",
" of",
"re",
"of ",
"t ",
", ",
"is",
"u",
"at",
" ",
"n ",
"or",
"which",
"f",
"m",
"as",
"it",
"that",
"\n",
"was",
"en",
" ",
" w",
"es",
" an",
" i",
"\r",
"f ",
"g",
"p",
"nd",
" s",
"nd ",
"ed ",
"w",
"ed",
"http://",
"for",
"te",
"ing",
"y ",
"The",
" c",
"ti",
"r ",
"his",
"st",
" in",
"ar",
"nt",
",",
" to",
"y",
"ng",
" h",
"with",
"le",
"al",
"to ",
"b",
"ou",
"be",
"were",
" b",
"se",
"o ",
"ent",
"ha",
"ng ",
"their",
"\"",
"hi",
"from",
" f",
"in ",
"de",
"ion",
"me",
"v",
".",
"ve",
"all",
"re ",
"ri",
"ro",
"is ",
"co",
"f t",
"are",
"ea",
". ",
"her",
" m",
"er ",
" p",
"es ",
"by",
"they",
"di",
"ra",
"ic",
"not",
"s, ",
"d t",
"at ",
"ce",
"la",
"h ",
"ne",
"as ",
"tio",
"on ",
"n t",
"io",
"we",
" a ",
"om",
", a",
"s o",
"ur",
"li",
"ll",
"ch",
"had",
"this",
"e t",
"g ",
"e\r\n",
" wh",
"ere",
" co",
"e o",
"a ",
"us",
" d",
"ss",
"\n\r\n",
"\r\n\r",
"=\"",
" be",
" e",
"s a",
"ma",
"one",
"t t",
"or ",
"but",
"el",
"so",
"l ",
"e s",
"s,",
"no",
"ter",
" wa",
"iv",
"ho",
"e a",
" r",
"hat",
"s t",
"ns",
"ch ",
"wh",
"tr",
"ut",
"/",
"have",
"ly ",
"ta",
" ha",
" on",
"tha",
"-",
" l",
"ati",
"en ",
"pe",
" re",
"there",
"ass",
"si",
" fo",
"wa",
"ec",
"our",
"who",
"its",
"z",
"fo",
"rs",
">",
"ot",
"un",
"<",
"im",
"th ",
"nc",
"ate",
"><",
"ver",
"ad",
" we",
"ly",
"ee",
" n",
"id",
" cl",
"ac",
"il",
"</",
"rt",
" wi",
"div",
"e, ",
" it",
"whi",
" ma",
"ge",
"x",
"e c",
"men",
".com",
];
static final List<Uint8List> _rcbBytes =
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false);
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
return entry.length > maxLen ? entry.length : maxLen;
});
static String encodeIfSmaller(String text) {
if (text.isEmpty || text.startsWith('s:')) return text;
final originalBytes = Uint8List.fromList(utf8.encode(text));
final compressed = compressBytes(originalBytes);
final encoded = base64Encode(compressed);
final candidate = 's:$encoded';
if (utf8.encode(candidate).length < originalBytes.length) {
return candidate;
}
return text;
}
static String? tryDecodePrefixed(String text) {
final trimmedLeft = text.trimLeft();
if (!trimmedLeft.startsWith('s:') || trimmedLeft.length <= 2) return null;
final encoded = trimmedLeft.substring(2);
try {
final compressed = _decodeBase64Flexible(encoded);
final decompressed = decompressBytes(compressed);
return utf8.decode(decompressed, allowMalformed: true);
} catch (_) {
return null;
}
}
static Uint8List compressBytes(Uint8List input) {
final out = BytesBuilder(copy: false);
final verbatim = <int>[];
int index = 0;
void flushVerbatim() {
if (verbatim.isEmpty) return;
if (verbatim.length == 1) {
out.addByte(_verbatimSingle);
out.addByte(verbatim[0]);
} else {
out.addByte(_verbatimRun);
out.addByte(verbatim.length - 1);
out.add(verbatim);
}
verbatim.clear();
}
while (index < input.length) {
int bestLen = 0;
int bestCode = -1;
final remaining = input.length - index;
final maxLen = remaining < _maxEntryLen ? remaining : _maxEntryLen;
for (int code = 0; code < _rcbBytes.length; code++) {
final entry = _rcbBytes[code];
final entryLen = entry.length;
if (entryLen == 0 || entryLen > maxLen || entryLen <= bestLen) {
continue;
}
if (_matches(input, index, entry)) {
bestLen = entryLen;
bestCode = code;
if (bestLen == maxLen) {
break;
}
}
}
if (bestCode >= 0) {
flushVerbatim();
out.addByte(bestCode);
index += bestLen;
continue;
}
verbatim.add(input[index]);
index++;
if (verbatim.length == 256) {
flushVerbatim();
}
}
flushVerbatim();
return out.toBytes();
}
static Uint8List decompressBytes(Uint8List input) {
final out = BytesBuilder(copy: false);
int index = 0;
while (index < input.length) {
final code = input[index];
if (code == _verbatimSingle) {
if (index + 1 >= input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim byte.');
}
out.addByte(input[index + 1]);
index += 2;
} else if (code == _verbatimRun) {
if (index + 1 >= input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim length.');
}
final len = input[index + 1] + 1;
final end = index + 2 + len;
if (end > input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim run.');
}
out.add(input.sublist(index + 2, end));
index = end;
} else {
if (code >= _rcbBytes.length) {
throw const FormatException('Invalid SMAZ stream: code out of range.');
}
out.add(_rcbBytes[code]);
index += 1;
}
}
return out.toBytes();
}
static bool _matches(Uint8List input, int offset, Uint8List entry) {
final len = entry.length;
if (offset + len > input.length) return false;
for (int i = 0; i < len; i++) {
if (input[offset + i] != entry[i]) return false;
}
return true;
}
static Uint8List _decodeBase64Flexible(String encoded) {
final trimmed = encoded.trim();
try {
return base64Decode(trimmed);
} catch (_) {
// Try base64url with missing padding.
var normalized = trimmed.replaceAll('-', '+').replaceAll('_', '/');
final pad = normalized.length % 4;
if (pad != 0) {
normalized = normalized.padRight(normalized.length + (4 - pad), '=');
}
return base64Decode(normalized);
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'connector/meshcore_connector.dart';
import 'screens/scanner_screen.dart';
import 'services/storage_service.dart';
import 'services/message_retry_service.dart';
import 'services/path_history_service.dart';
import 'services/app_settings_service.dart';
import 'services/notification_service.dart';
import 'services/ble_debug_log_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize services
final storage = StorageService();
final connector = MeshCoreConnector();
final pathHistoryService = PathHistoryService(storage);
final retryService = MessageRetryService(storage);
final appSettingsService = AppSettingsService();
final bleDebugLogService = BleDebugLogService();
// Load settings
await appSettingsService.loadSettings();
// Initialize notification service
final notificationService = NotificationService();
await notificationService.initialize();
// Wire up connector with services
connector.initialize(
retryService: retryService,
pathHistoryService: pathHistoryService,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
);
await connector.loadContactCache();
await connector.loadChannelSettings();
// Load persisted channel messages
await connector.loadAllChannelMessages();
runApp(MeshCoreApp(
connector: connector,
retryService: retryService,
pathHistoryService: pathHistoryService,
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
));
}
class MeshCoreApp extends StatelessWidget {
final MeshCoreConnector connector;
final MessageRetryService retryService;
final PathHistoryService pathHistoryService;
final StorageService storage;
final AppSettingsService appSettingsService;
final BleDebugLogService bleDebugLogService;
const MeshCoreApp({
super.key,
required this.connector,
required this.retryService,
required this.pathHistoryService,
required this.storage,
required this.appSettingsService,
required this.bleDebugLogService,
});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: retryService),
ChangeNotifierProvider.value(value: pathHistoryService),
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
Provider.value(value: storage),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
return MaterialApp(
title: 'MeshCore Open',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: _themeModeFromSetting(settingsService.settings.themeMode),
home: const ScannerScreen(),
);
},
),
);
}
ThemeMode _themeModeFromSetting(String value) {
switch (value) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
}
+108
View File
@@ -0,0 +1,108 @@
class AppSettings {
final bool clearPathOnMaxRetry;
final bool mapShowRepeaters;
final bool mapShowChatNodes;
final bool mapShowOtherNodes;
final double mapTimeFilterHours; // 0 = all time
final bool mapKeyPrefixEnabled;
final String mapKeyPrefix;
final bool mapShowMarkers;
final bool notificationsEnabled;
final bool notifyOnNewMessage;
final bool notifyOnNewAdvert;
final bool autoRouteRotationEnabled;
final String themeMode;
final Map<String, String> batteryChemistryByDeviceId;
AppSettings({
this.clearPathOnMaxRetry = false,
this.mapShowRepeaters = true,
this.mapShowChatNodes = true,
this.mapShowOtherNodes = true,
this.mapTimeFilterHours = 0, // Default to all time
this.mapKeyPrefixEnabled = false,
this.mapKeyPrefix = '',
this.mapShowMarkers = true,
this.notificationsEnabled = true,
this.notifyOnNewMessage = true,
this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false,
this.themeMode = 'system',
Map<String, String>? batteryChemistryByDeviceId,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
Map<String, dynamic> toJson() {
return {
'clear_path_on_max_retry': clearPathOnMaxRetry,
'map_show_repeaters': mapShowRepeaters,
'map_show_chat_nodes': mapShowChatNodes,
'map_show_other_nodes': mapShowOtherNodes,
'map_time_filter_hours': mapTimeFilterHours,
'map_key_prefix_enabled': mapKeyPrefixEnabled,
'map_key_prefix': mapKeyPrefix,
'map_show_markers': mapShowMarkers,
'notifications_enabled': notificationsEnabled,
'notify_on_new_message': notifyOnNewMessage,
'notify_on_new_advert': notifyOnNewAdvert,
'auto_route_rotation_enabled': autoRouteRotationEnabled,
'theme_mode': themeMode,
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
};
}
factory AppSettings.fromJson(Map<String, dynamic> json) {
return AppSettings(
clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false,
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
notifyOnNewMessage: json['notify_on_new_message'] as bool? ?? true,
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system',
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()),
) ??
{},
);
}
AppSettings copyWith({
bool? clearPathOnMaxRetry,
bool? mapShowRepeaters,
bool? mapShowChatNodes,
bool? mapShowOtherNodes,
double? mapTimeFilterHours,
bool? mapKeyPrefixEnabled,
String? mapKeyPrefix,
bool? mapShowMarkers,
bool? notificationsEnabled,
bool? notifyOnNewMessage,
bool? notifyOnNewAdvert,
bool? autoRouteRotationEnabled,
String? themeMode,
Map<String, String>? batteryChemistryByDeviceId,
}) {
return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
mapShowRepeaters: mapShowRepeaters ?? this.mapShowRepeaters,
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
notifyOnNewMessage: notifyOnNewMessage ?? this.notifyOnNewMessage,
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode,
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'dart:convert';
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
class Channel {
final int index;
final String name;
final Uint8List psk; // 16 bytes
Channel({
required this.index,
required this.name,
required this.psk,
});
String get pskBase64 => base64Encode(psk);
bool get isEmpty => name.isEmpty && psk.every((b) => b == 0);
bool get isPublicChannel => pskBase64 == publicChannelPsk;
static Channel? fromFrame(Uint8List data) {
// CHANNEL_INFO format:
// [0] = RESP_CODE_CHANNEL_INFO (18)
// [1] = channel_idx
// [2-33] = name (32 bytes, null-terminated)
// [34-49] = psk (16 bytes)
if (data.length < 50) return null;
if (data[0] != respCodeChannelInfo) return null;
final index = data[1];
final name = readCString(data, 2, 32);
final psk = Uint8List.fromList(data.sublist(34, 50));
return Channel(index: index, name: name, psk: psk);
}
static Channel empty(int index) {
return Channel(
index: index,
name: '',
psk: Uint8List(16),
);
}
static Channel fromPsk(int index, String name, String pskBase64) {
final pskBytes = base64Decode(pskBase64);
final psk = Uint8List(16);
for (int i = 0; i < pskBytes.length && i < 16; i++) {
psk[i] = pskBytes[i];
}
return Channel(index: index, name: name, psk: psk);
}
static const String publicChannelPsk = 'izOH6cXN6mrJ5e26oRXNcg==';
}
+170
View File
@@ -0,0 +1,170 @@
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
import '../helpers/smaz.dart';
enum ChannelMessageStatus { pending, sent, failed }
class Repeat {
final Uint8List? repeaterKey;
final String repeaterName;
final int tripTimeMs;
final List<Uint8List>? path;
Repeat({
this.repeaterKey,
required this.repeaterName,
required this.tripTimeMs,
this.path,
});
String? get repeaterKeyHex =>
repeaterKey != null ? pubKeyToHex(repeaterKey!) : null;
}
class ChannelMessage {
final Uint8List? senderKey;
final String senderName;
final String text;
final DateTime timestamp;
final bool isOutgoing;
final ChannelMessageStatus status;
final List<Repeat> repeats;
final int repeatCount;
final int? pathLength;
final Uint8List pathBytes;
final int? channelIndex;
ChannelMessage({
this.senderKey,
required this.senderName,
required this.text,
required this.timestamp,
required this.isOutgoing,
this.status = ChannelMessageStatus.pending,
this.repeats = const [],
this.repeatCount = 0,
this.pathLength,
Uint8List? pathBytes,
this.channelIndex,
}) : pathBytes = pathBytes ?? Uint8List(0);
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
ChannelMessage copyWith({
ChannelMessageStatus? status,
List<Repeat>? repeats,
int? repeatCount,
int? pathLength,
Uint8List? pathBytes,
}) {
return ChannelMessage(
senderKey: senderKey,
senderName: senderName,
text: text,
timestamp: timestamp,
isOutgoing: isOutgoing,
status: status ?? this.status,
repeats: repeats ?? this.repeats,
repeatCount: repeatCount ?? this.repeatCount,
pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes,
channelIndex: channelIndex,
);
}
static ChannelMessage? fromFrame(Uint8List data) {
// CHANNEL_MSG_RECV format varies by version:
// V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...]
// Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text
if (data.length < 8) return null;
final code = data[0];
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
return null;
}
int timestampOffset, textOffset, pathLenOffset, txtTypeOffset;
Uint8List pathBytes = Uint8List(0);
int channelIdx;
if (code == respCodeChannelMsgRecvV3) {
channelIdx = data[4];
pathLenOffset = 5;
final pathLen = data[pathLenOffset].toSigned(8);
var cursor = 6;
final hasPathBytesFlag = (data[2] & 0x01) != 0;
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
final hasValidTxtType =
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen;
}
txtTypeOffset = cursor;
cursor += 1; // txt_type
timestampOffset = cursor;
textOffset = cursor + 4;
} else {
channelIdx = data[1];
pathLenOffset = 2;
txtTypeOffset = 3;
timestampOffset = 4;
textOffset = 8;
}
if (data.length < textOffset + 1) return null;
final txtType = data[txtTypeOffset];
if (txtType != txtTypePlain) {
return null;
}
final pathLen = data[pathLenOffset].toSigned(8);
final timestampRaw = readUint32LE(data, timestampOffset);
final text = readCString(data, textOffset, data.length - textOffset);
// Extract sender name and actual message from "name: msg" format
String senderName = 'Unknown';
String actualText = text;
final colonIndex = text.indexOf(':');
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
actualText = text.substring(offset);
}
}
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: pathLen,
pathBytes: pathBytes,
channelIndex: channelIdx,
);
}
static ChannelMessage outgoing(String text, String senderName, int channelIndex) {
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: text,
timestamp: DateTime.now(),
isOutgoing: true,
status: ChannelMessageStatus.pending,
pathLength: null,
pathBytes: Uint8List(0),
channelIndex: channelIndex,
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
class Contact {
final Uint8List publicKey;
final String name;
final int type;
final int pathLength; // -1 = flood, 0+ = direct hops
final Uint8List path;
final double? latitude;
final double? longitude;
final DateTime lastSeen;
Contact({
required this.publicKey,
required this.name,
required this.type,
required this.pathLength,
required this.path,
this.latitude,
this.longitude,
required this.lastSeen,
});
String get publicKeyHex => pubKeyToHex(publicKey);
String get typeLabel {
switch (type) {
case advTypeChat:
return 'Chat';
case advTypeRepeater:
return 'Repeater';
case advTypeRoom:
return 'Room';
case advTypeSensor:
return 'Sensor';
default:
return 'Unknown';
}
}
String get pathLabel {
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
}
bool get hasLocation => latitude != null && longitude != null;
String get pathIdList {
if (path.isEmpty) return '';
final parts = <String>[];
final groupSize = pathHashSize;
for (int i = 0; i < path.length; i += groupSize) {
final end = (i + groupSize) <= path.length ? (i + groupSize) : path.length;
final chunk = path.sublist(i, end);
parts.add(
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
);
}
return parts.join(',');
}
static Contact? fromFrame(Uint8List data) {
if (data.length < contactFrameSize) return null;
if (data[0] != respCodeContact) return null;
final pubKey = Uint8List.fromList(
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
);
final type = data[contactTypeOffset];
final pathLen = data[contactPathLenOffset].toSigned(8);
final safePathLen = pathLen > 0
? (pathLen > maxPathSize ? maxPathSize : pathLen)
: 0;
final pathBytes = safePathLen > 0
? Uint8List.fromList(
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
)
: Uint8List(0);
final name = readCString(data, contactNameOffset, maxNameSize);
final lastmod = readUint32LE(data, contactLastmodOffset);
double? lat, lon;
final latRaw = readInt32LE(data, contactLatOffset);
final lonRaw = readInt32LE(data, contactLonOffset);
if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6;
lon = lonRaw / 1e6;
}
return Contact(
publicKey: pubKey,
name: name.isEmpty ? 'Unknown' : name,
type: type,
pathLength: pathLen,
path: pathBytes,
latitude: lat,
longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
);
}
@override
bool operator ==(Object other) =>
other is Contact && publicKeyHex == other.publicKeyHex;
@override
int get hashCode => publicKeyHex.hashCode;
}
+37
View File
@@ -0,0 +1,37 @@
class ContactGroup {
final String name;
final List<String> memberKeys;
const ContactGroup({
required this.name,
required this.memberKeys,
});
ContactGroup copyWith({
String? name,
List<String>? memberKeys,
}) {
return ContactGroup(
name: name ?? this.name,
memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'members': memberKeys,
};
}
factory ContactGroup.fromJson(Map<String, dynamic> json) {
final members = (json['members'] as List?)
?.map((value) => value.toString())
.toList() ??
<String>[];
return ContactGroup(
name: json['name'] as String? ?? '',
memberKeys: members,
);
}
}
+125
View File
@@ -0,0 +1,125 @@
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
enum MessageStatus { pending, sent, delivered, failed }
class Message {
final Uint8List senderKey;
final String text;
final DateTime timestamp;
final bool isOutgoing;
final bool isCli;
final MessageStatus status;
// NEW: Retry logic fields
final String? messageId;
final int retryCount;
final int? estimatedTimeoutMs;
final Uint8List? expectedAckHash;
final DateTime? sentAt;
final DateTime? deliveredAt;
final int? tripTimeMs;
final bool forceFlood;
final int? pathLength;
final Uint8List pathBytes;
Message({
required this.senderKey,
required this.text,
required this.timestamp,
required this.isOutgoing,
this.isCli = false,
this.status = MessageStatus.pending,
this.messageId,
this.retryCount = 0,
this.estimatedTimeoutMs,
this.expectedAckHash,
this.sentAt,
this.deliveredAt,
this.tripTimeMs,
this.forceFlood = false,
this.pathLength,
Uint8List? pathBytes,
}) : pathBytes = pathBytes ?? Uint8List(0);
String get senderKeyHex => pubKeyToHex(senderKey);
Message copyWith({
MessageStatus? status,
int? retryCount,
int? estimatedTimeoutMs,
Uint8List? expectedAckHash,
DateTime? sentAt,
DateTime? deliveredAt,
int? tripTimeMs,
int? pathLength,
Uint8List? pathBytes,
bool? isCli,
}) {
return Message(
senderKey: senderKey,
text: text,
timestamp: timestamp,
isOutgoing: isOutgoing,
isCli: isCli ?? this.isCli,
status: status ?? this.status,
messageId: messageId,
retryCount: retryCount ?? this.retryCount,
estimatedTimeoutMs: estimatedTimeoutMs ?? this.estimatedTimeoutMs,
expectedAckHash: expectedAckHash ?? this.expectedAckHash,
sentAt: sentAt ?? this.sentAt,
deliveredAt: deliveredAt ?? this.deliveredAt,
tripTimeMs: tripTimeMs ?? this.tripTimeMs,
forceFlood: forceFlood,
pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes,
);
}
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) {
if (data.length < msgTextOffset + 1) return null;
final code = data[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
final senderKey = Uint8List.fromList(
data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize),
);
final timestampRaw = readUint32LE(data, msgTimestampOffset);
final flags = data[msgFlagsOffset];
if ((flags >> 2) != txtTypePlain) {
return null;
}
final text = readCString(data, msgTextOffset, data.length - msgTextOffset);
return Message(
senderKey: senderKey,
text: text,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
pathBytes: Uint8List(0),
);
}
static Message outgoing(
Uint8List recipientKey,
String text, {
int? pathLength,
Uint8List? pathBytes,
}) {
return Message(
senderKey: recipientKey,
text: text,
timestamp: DateTime.now(),
isOutgoing: true,
isCli: false,
status: MessageStatus.pending,
pathLength: pathLength,
pathBytes: pathBytes,
);
}
}
+85
View File
@@ -0,0 +1,85 @@
class PathRecord {
final int hopCount;
final int tripTimeMs;
final DateTime timestamp;
final bool wasFloodDiscovery;
final List<int> pathBytes;
final int successCount;
final int failureCount;
PathRecord({
required this.hopCount,
required this.tripTimeMs,
required this.timestamp,
required this.wasFloodDiscovery,
required this.pathBytes,
required this.successCount,
required this.failureCount,
});
String get displayText =>
'$hopCount ${hopCount == 1 ? 'hop' : 'hops'} - ${(tripTimeMs / 1000).toStringAsFixed(2)}s';
Map<String, dynamic> toJson() {
return {
'hop_count': hopCount,
'trip_time_ms': tripTimeMs,
'timestamp': timestamp.toIso8601String(),
'was_flood': wasFloodDiscovery,
'path_bytes': pathBytes,
'success_count': successCount,
'failure_count': failureCount,
};
}
factory PathRecord.fromJson(Map<String, dynamic> json) {
return PathRecord(
hopCount: json['hop_count'] as int,
tripTimeMs: json['trip_time_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
wasFloodDiscovery: json['was_flood'] as bool,
pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
);
}
}
class ContactPathHistory {
final String contactPubKeyHex;
final List<PathRecord> recentPaths;
ContactPathHistory({
required this.contactPubKeyHex,
required this.recentPaths,
});
PathRecord? get fastest {
if (recentPaths.isEmpty) return null;
return recentPaths.reduce((a, b) => a.tripTimeMs < b.tripTimeMs ? a : b);
}
PathRecord? get mostRecent {
if (recentPaths.isEmpty) return null;
return recentPaths.first;
}
Map<String, dynamic> toJson() {
return {
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
};
}
factory ContactPathHistory.fromJson(
String contactPubKeyHex, Map<String, dynamic> json) {
final pathsList = (json['recent_paths'] as List?)
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
.toList() ??
[];
return ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: pathsList,
);
}
}
+11
View File
@@ -0,0 +1,11 @@
class PathSelection {
final List<int> pathBytes;
final int hopCount;
final bool useFlood;
const PathSelection({
required this.pathBytes,
required this.hopCount,
required this.useFlood,
});
}
+107
View File
@@ -0,0 +1,107 @@
enum LoRaBandwidth {
bw7_8(7800, '7.8 kHz'),
bw10_4(10400, '10.4 kHz'),
bw15_6(15600, '15.6 kHz'),
bw20_8(20800, '20.8 kHz'),
bw31_25(31250, '31.25 kHz'),
bw41_7(41700, '41.7 kHz'),
bw62_5(62500, '62.5 kHz'),
bw125(125000, '125 kHz'),
bw250(250000, '250 kHz'),
bw500(500000, '500 kHz');
final int hz;
final String label;
const LoRaBandwidth(this.hz, this.label);
}
enum LoRaSpreadingFactor {
sf5(5, 'SF5'),
sf6(6, 'SF6'),
sf7(7, 'SF7'),
sf8(8, 'SF8'),
sf9(9, 'SF9'),
sf10(10, 'SF10'),
sf11(11, 'SF11'),
sf12(12, 'SF12');
final int value;
final String label;
const LoRaSpreadingFactor(this.value, this.label);
}
enum LoRaCodingRate {
cr4_5(5, '4/5'),
cr4_6(6, '4/6'),
cr4_7(7, '4/7'),
cr4_8(8, '4/8');
final int value;
final String label;
const LoRaCodingRate(this.value, this.label);
}
class RadioSettings {
final double frequencyMHz;
final LoRaBandwidth bandwidth;
final LoRaSpreadingFactor spreadingFactor;
final LoRaCodingRate codingRate;
final int txPowerDbm;
RadioSettings({
required this.frequencyMHz,
required this.bandwidth,
required this.spreadingFactor,
required this.codingRate,
required this.txPowerDbm,
});
// Preset configurations
static RadioSettings get preset915MHz => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
static RadioSettings get preset868MHz => RadioSettings(
frequencyMHz: 868.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
);
static RadioSettings get preset433MHz => RadioSettings(
frequencyMHz: 433.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
static RadioSettings get presetLongRange => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf12,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
);
static RadioSettings get presetFastSpeed => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw500,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
int get frequencyHz => (frequencyMHz * 1000).round();
int get bandwidthHz => bandwidth.hz;
int get sf => spreadingFactor.value;
int get cr => codingRate.value;
}
+484
View File
@@ -0,0 +1,484 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../services/app_settings_service.dart';
import '../services/notification_service.dart';
class AppSettingsScreen extends StatelessWidget {
const AppSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('App Settings'),
centerTitle: true,
),
body: Consumer2<AppSettingsService, MeshCoreConnector>(
builder: (context, settingsService, connector, child) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildAppearanceCard(context, settingsService),
const SizedBox(height: 16),
_buildNotificationsCard(context, settingsService),
const SizedBox(height: 16),
_buildMessagingCard(context, settingsService),
const SizedBox(height: 16),
_buildBatteryCard(context, settingsService, connector),
const SizedBox(height: 16),
_buildMapSettingsCard(context, settingsService),
],
);
},
),
);
}
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Appearance',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: const Text('Theme'),
subtitle: Text(_themeModeLabel(settingsService.settings.themeMode)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService),
),
],
),
);
}
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Notifications',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.notifications_outlined),
title: const Text('Enable Notifications'),
subtitle: const Text('Receive notifications for messages and adverts'),
value: settingsService.settings.notificationsEnabled,
onChanged: (value) async {
if (value) {
// Request permission when enabling
final granted = await NotificationService().requestPermissions();
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Notification permission denied'),
duration: Duration(seconds: 2),
),
);
}
return;
}
}
await settingsService.setNotificationsEnabled(value);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Notifications enabled'
: 'Notifications disabled'),
duration: const Duration(seconds: 2),
),
);
}
},
),
const Divider(height: 1),
SwitchListTile(
secondary: Icon(
Icons.message_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Message Notifications',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when receiving new messages',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
value: settingsService.settings.notifyOnNewMessage,
onChanged: settingsService.settings.notificationsEnabled
? (value) {
settingsService.setNotifyOnNewMessage(value);
}
: null,
),
const Divider(height: 1),
SwitchListTile(
secondary: Icon(
Icons.cell_tower,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Advertisement Notifications',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when new nodes are discovered',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
value: settingsService.settings.notifyOnNewAdvert,
onChanged: settingsService.settings.notificationsEnabled
? (value) {
settingsService.setNotifyOnNewAdvert(value);
}
: null,
),
],
),
);
}
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Messaging',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.refresh_outlined),
title: const Text('Clear Path on Max Retry'),
subtitle: const Text('Reset contact path after 5 failed send attempts'),
value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Paths will be cleared after 5 failed retries'
: 'Paths will not be auto-cleared'),
duration: const Duration(seconds: 2),
),
);
},
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.alt_route),
title: const Text('Auto Route Rotation'),
subtitle: const Text('Cycle between best paths and flood mode'),
value: settingsService.settings.autoRouteRotationEnabled,
onChanged: (value) {
settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Auto route rotation enabled'
: 'Auto route rotation disabled'),
duration: const Duration(seconds: 2),
),
);
},
),
],
),
);
}
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Map Display',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: const Text('Show Repeaters'),
subtitle: const Text('Display repeater nodes on the map'),
value: settingsService.settings.mapShowRepeaters,
onChanged: (value) {
settingsService.setMapShowRepeaters(value);
},
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.chat_outlined),
title: const Text('Show Chat Nodes'),
subtitle: const Text('Display chat nodes on the map'),
value: settingsService.settings.mapShowChatNodes,
onChanged: (value) {
settingsService.setMapShowChatNodes(value);
},
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.people_outline),
title: const Text('Show Other Nodes'),
subtitle: const Text('Display other node types on the map'),
value: settingsService.settings.mapShowOtherNodes,
onChanged: (value) {
settingsService.setMapShowOtherNodes(value);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.timer_outlined),
title: const Text('Time Filter'),
subtitle: Text(
settingsService.settings.mapTimeFilterHours == 0
? 'Show all nodes'
: 'Show nodes from last ${settingsService.settings.mapTimeFilterHours.toInt()} hours',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService),
),
],
),
);
}
Widget _buildBatteryCard(
BuildContext context,
AppSettingsService settingsService,
MeshCoreConnector connector,
) {
final deviceId = connector.device?.remoteId.toString();
final isConnected = connector.isConnected && deviceId != null;
final selection =
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Battery',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.battery_full),
title: const Text('Battery Chemistry'),
subtitle: Text(
isConnected
? 'Set per device (${connector.device!.platformName})'
: 'Connect to a device to choose',
),
trailing: DropdownButton<String>(
value: selection,
onChanged: isConnected
? (value) {
if (value != null) {
settingsService.setBatteryChemistryForDevice(deviceId, value);
}
}
: null,
items: const [
DropdownMenuItem(
value: 'nmc',
child: Text('18650 NMC (3.0-4.2V)'),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text('LiFePO4 (2.6-3.65V)'),
),
DropdownMenuItem(
value: 'lipo',
child: Text('LiPo (3.0-4.2V)'),
),
],
),
),
],
),
);
}
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String>(
title: const Text('System default'),
value: 'system',
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
if (value != null) {
settingsService.setThemeMode(value);
Navigator.pop(context);
}
},
),
RadioListTile<String>(
title: const Text('Light'),
value: 'light',
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
if (value != null) {
settingsService.setThemeMode(value);
Navigator.pop(context);
}
},
),
RadioListTile<String>(
title: const Text('Dark'),
value: 'dark',
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
if (value != null) {
settingsService.setThemeMode(value);
Navigator.pop(context);
}
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
String _themeModeLabel(String value) {
switch (value) {
case 'light':
return 'Light';
case 'dark':
return 'Dark';
default:
return 'System default';
}
}
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Map Time Filter'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Show nodes discovered within:'),
const SizedBox(height: 16),
ListTile(
title: const Text('All time'),
leading: Radio<double>(
value: 0,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
ListTile(
title: const Text('Last hour'),
leading: Radio<double>(
value: 1,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
ListTile(
title: const Text('Last 6 hours'),
leading: Radio<double>(
value: 6,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
ListTile(
title: const Text('Last 24 hours'),
leading: Radio<double>(
value: 24,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
ListTile(
title: const Text('Last week'),
leading: Radio<double>(
value: 168,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
}
+384
View File
@@ -0,0 +1,384 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart';
enum _BleLogView { frames, rawLogRx }
class BleDebugLogScreen extends StatefulWidget {
const BleDebugLogScreen({super.key});
@override
State<BleDebugLogScreen> createState() => _BleDebugLogScreenState();
}
class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
_BleLogView _view = _BleLogView.frames;
@override
Widget build(BuildContext context) {
return Consumer<BleDebugLogService>(
builder: (context, logService, _) {
final entries = logService.entries.reversed.toList();
final rawEntries = logService.rawLogRxEntries.reversed.toList();
final showingFrames = _view == _BleLogView.frames;
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('BLE Debug Log'),
actions: [
IconButton(
tooltip: 'Copy log',
icon: const Icon(Icons.copy),
onPressed: hasEntries
? () async {
final text = showingFrames
? entries
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
.join('\n')
: rawEntries
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('BLE log copied')),
);
}
: null,
),
IconButton(
tooltip: 'Clear log',
icon: const Icon(Icons.delete_outline),
onPressed: hasEntries
? () {
logService.clear();
}
: null,
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>(
segments: const [
ButtonSegment(value: _BleLogView.frames, label: Text('Frames')),
ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')),
],
selected: {_view},
onSelectionChanged: (selection) {
setState(() => _view = selection.first);
},
),
),
const SizedBox(height: 8),
Expanded(
child: hasEntries
? ListView.separated(
itemCount: showingFrames ? entries.length : rawEntries.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
if (showingFrames) {
final entry = entries[index];
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(entry.description),
subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true,
leading: Icon(
entry.outgoing ? Icons.upload : Icons.download,
size: 18,
),
);
}
final entry = rawEntries[index];
final info = _decodeRawPacket(entry.payload);
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(info.title),
subtitle: Text('${info.summary}\n$time'),
isThreeLine: true,
leading: const Icon(Icons.download, size: 18),
onTap: () => _showRawDialog(context, info),
);
},
)
: const Center(
child: Text('No BLE activity yet'),
),
),
],
),
);
},
);
}
void _showRawDialog(BuildContext context, _RawPacketInfo info) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(info.title),
content: SingleChildScrollView(
child: SelectableText(info.rawHex),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
_RawPacketInfo _decodeRawPacket(Uint8List raw) {
if (raw.length < 2) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • invalid',
summary: 'Packet too short',
rawHex: _bytesToHex(raw),
);
}
var index = 0;
final header = raw[index++];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
final payloadVer = (header >> 6) & 0x03;
final hasTransport = routeType == 0 || routeType == 3;
if (hasTransport) {
if (raw.length < index + 4) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Missing transport codes',
rawHex: _bytesToHex(raw),
);
}
index += 4;
}
if (raw.length <= index) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Missing path length',
rawHex: _bytesToHex(raw),
);
}
final pathLen = raw[index++];
if (raw.length < index + pathLen) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Truncated path',
rawHex: _bytesToHex(raw),
);
}
final pathBytes = raw.sublist(index, index + pathLen);
index += pathLen;
if (raw.length <= index) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Missing payload',
rawHex: _bytesToHex(raw),
);
}
final payload = raw.sublist(index);
final title = 'RX ${_payloadTypeLabel(payloadType)}${_routeLabel(routeType)} • v$payloadVer';
final summary = _decodePayloadSummary(payloadType, payload);
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
final detail = '$summary$pathSummary • len=${raw.length}';
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw));
}
String _decodePayloadSummary(int payloadType, Uint8List payload) {
switch (payloadType) {
case 0x00: // REQ
return 'REQ payload=${payload.length} bytes';
case 0x01: // RESP
return 'RESP payload=${payload.length} bytes';
case 0x02: // TXT
return 'TXT payload=${payload.length} bytes';
case 0x03: // ACK
if (payload.length < 4) return 'ACK (short)';
return 'ACK crc=${_bytesToHex(payload.sublist(0, 4))}';
case 0x04: // ADVERT
return _decodeAdvertSummary(payload);
case 0x05: // GROUP_TXT
if (payload.length < 3) return 'GRP_TXT (short)';
final channelHash = payload[0].toRadixString(16).padLeft(2, '0');
final mac = _bytesToHex(payload.sublist(1, 3));
final cipherLen = payload.length - 3;
return 'GRP_TXT hash=$channelHash mac=$mac cipher=$cipherLen';
case 0x06: // GROUP_DATA
return 'GRP_DATA payload=${payload.length} bytes';
case 0x07: // ANON_REQ
return 'ANON_REQ payload=${payload.length} bytes';
case 0x08: // PATH
return 'PATH payload=${payload.length} bytes';
case 0x09: // TRACE
return 'TRACE payload=${payload.length} bytes';
case 0x0A: // MULTIPART
return 'MULTIPART payload=${payload.length} bytes';
case 0x0B: // CONTROL
return _decodeControlSummary(payload);
case 0x0F: // RAW
return 'RAW payload=${payload.length} bytes';
default:
return 'TYPE_$payloadType payload=${payload.length} bytes';
}
}
String _decodeAdvertSummary(Uint8List payload) {
if (payload.length < 101) {
return 'ADVERT (short)';
}
var offset = 0;
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false);
offset += 32;
final timestamp = readUint32LE(payload, offset);
offset += 4;
offset += 64; // signature
final flags = payload[offset++];
final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0;
final hasFeature2 = (flags & 0x40) != 0;
final hasName = (flags & 0x80) != 0;
String? name;
double? lat;
double? lon;
if (hasLocation && payload.length >= offset + 8) {
lat = readInt32LE(payload, offset) / 1000000.0;
lon = readInt32LE(payload, offset + 4) / 1000000.0;
offset += 8;
}
if (hasFeature1) offset += 2;
if (hasFeature2) offset += 2;
if (hasName && payload.length > offset) {
final rawName = String.fromCharCodes(payload.sublist(offset));
final nul = rawName.indexOf('\u0000');
name = nul >= 0 ? rawName.substring(0, nul) : rawName;
name = name.trim();
}
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
}
String _decodeControlSummary(Uint8List payload) {
if (payload.isEmpty) return 'CONTROL (empty)';
final flags = payload[0];
final subType = flags & 0xF0;
if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = payload[1];
final tag = readUint32LE(payload, 2);
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
}
if (subType == 0x90) {
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
final nodeType = flags & 0x0F;
final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0;
final tag = readUint32LE(payload, 2);
final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
}
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
}
String _payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'REQ';
case 0x01:
return 'RESP';
case 0x02:
return 'TXT';
case 0x03:
return 'ACK';
case 0x04:
return 'ADVERT';
case 0x05:
return 'GRP_TXT';
case 0x06:
return 'GRP_DATA';
case 0x07:
return 'ANON_REQ';
case 0x08:
return 'PATH';
case 0x09:
return 'TRACE';
case 0x0A:
return 'MULTIPART';
case 0x0B:
return 'CONTROL';
case 0x0F:
return 'RAW';
default:
return 'TYPE_$payloadType';
}
}
String _routeLabel(int routeType) {
switch (routeType) {
case 0:
return 'TRANS_FLOOD';
case 1:
return 'FLOOD';
case 2:
return 'DIRECT';
case 3:
return 'TRANS_DIRECT';
default:
return 'ROUTE_$routeType';
}
}
String _deviceRoleLabel(int role) {
switch (role) {
case 0x01:
return 'Chat';
case 0x02:
return 'Repeater';
case 0x03:
return 'Room';
case 0x04:
return 'Sensor';
default:
return 'Unknown';
}
}
String _bytesToHex(Uint8List bytes, {bool spaced = true}) {
if (bytes.isEmpty) return '';
if (!spaced) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
}
class _RawPacketInfo {
final String title;
final String summary;
final String rawHex;
_RawPacketInfo({
required this.title,
required this.summary,
required this.rawHex,
});
}
+555
View File
@@ -0,0 +1,555 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
import '../utils/emoji_utils.dart';
import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart';
import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget {
final Channel channel;
const ChannelChatScreen({
super.key,
required this.channel,
});
@override
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
}
class _ChannelChatScreenState extends State<ChannelChatScreen> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Icon(
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.channel.name.isEmpty
? 'Channel ${widget.channel.index}'
: widget.channel.name,
style: const TextStyle(fontSize: 16),
),
Text(
widget.channel.isPublicChannel ? 'Public' : 'Private',
style: const TextStyle(fontSize: 12),
),
],
),
),
],
),
centerTitle: false,
),
body: Column(
children: [
Expanded(
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
widget.channel.isPublicChannel
? Icons.public
: Icons.tag,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No messages yet',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Send a message to get started',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
cacheExtent: 0,
addAutomaticKeepAlives: false,
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return _buildMessageBubble(message);
},
);
},
),
),
_buildMessageComposer(),
],
),
);
}
Widget _buildMessageBubble(ChannelMessage message) {
final isOutgoing = message.isOutgoing;
final gifId = _parseGifId(message.text);
final poi = _parsePoiMessage(message.text);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
_buildAvatar(message.senderName),
const SizedBox(width: 8),
],
Flexible(
child: GestureDetector(
onLongPress: () => _showMessagePathInfo(message),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
fallbackTextColor: isOutgoing
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
)
else
Text(
message.text,
style: const TextStyle(fontSize: 14),
),
if (message.pathBytes.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'via ${_formatPathPrefixes(message.pathBytes)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(Icons.repeat, size: 12, color: Colors.grey[600]),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status == ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color: message.status == ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
],
),
),
),
),
],
),
);
}
String? _parseGifId(String text) {
final trimmed = text.trim();
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
return match?.group(1);
}
_PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim();
final match = RegExp(r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|').firstMatch(trimmed);
if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
if (lat == null || lon == null) return null;
final label = match.group(3) ?? '';
return _PoiInfo(lat: lat, lon: lon, label: label);
}
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
final colorScheme = Theme.of(context).colorScheme;
final textColor =
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.location_on_outlined, color: channelColor),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MapScreen(
highlightPosition: LatLng(poi.lat, poi.lon),
highlightLabel: poi.label,
),
),
);
},
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'POI Shared',
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
),
),
if (poi.label.isNotEmpty)
Text(
poi.label,
style: TextStyle(
color: metaColor,
fontSize: 12,
),
),
],
),
),
],
);
}
void _showGifPicker(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => GifPicker(
onGifSelected: (gifId) {
_textController.text = 'g:$gifId';
},
),
);
}
Widget _buildAvatar(String senderName) {
final initial = _getFirstCharacterOrEmoji(senderName);
final color = _getColorForName(senderName);
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.2),
child: Text(
initial,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
),
),
);
}
String _getFirstCharacterOrEmoji(String name) {
if (name.isEmpty) return '?';
final emoji = firstEmoji(name);
if (emoji != null) return emoji;
final runes = name.runes.toList();
if (runes.isEmpty) return '?';
return String.fromCharCode(runes[0]).toUpperCase();
}
Color _getColorForName(String name) {
// Generate a consistent color based on the name hash
final hash = name.hashCode;
final colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.pink,
Colors.teal,
Colors.indigo,
Colors.cyan,
Colors.amber,
Colors.deepOrange,
];
return colors[hash.abs() % colors.length];
}
Widget _buildMessageComposer() {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: 'Send GIF',
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Row(
children: [
Expanded(
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
fallbackTextColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
width: 160,
height: 110,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _textController.clear(),
),
],
);
}
return TextField(
controller: _textController,
decoration: InputDecoration(
hintText: 'Type a message...',
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,
),
],
),
);
}
void _sendMessage() {
final text = _textController.text.trim();
if (text.isEmpty) return;
context.read<MeshCoreConnector>().sendChannelMessage(widget.channel, text);
_textController.clear();
}
String _formatTime(DateTime time) {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inDays > 0) {
return '${time.day}/${time.month} ${time.hour}:${time.minute.toString().padLeft(2, '0')}';
} else {
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
}
}
void _showMessagePathInfo(ChannelMessage message) {
final pathPrefixes =
message.pathBytes.isNotEmpty ? _formatPathPrefixes(message.pathBytes) : null;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Packet Path'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow('Sender', message.senderName),
_buildDetailRow('Time', _formatTime(message.timestamp)),
_buildDetailRow('Repeats', message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
if (pathPrefixes != null) _buildDetailRow('Prefixes', pathPrefixes),
if (pathPrefixes == null) ...[
const SizedBox(height: 8),
const Text(
'Hop details are not provided for this packet.',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
String _formatPathLabel(int? pathLength) {
if (pathLength == null) return 'Unknown';
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
}
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 70,
child: Text(label, style: TextStyle(color: Colors.grey[600])),
),
Expanded(child: Text(value)),
],
),
);
}
}
class _PoiInfo {
final double lat;
final double lon;
final String label;
const _PoiInfo({
required this.lat,
required this.lon,
required this.label,
});
}
+439
View File
@@ -0,0 +1,439 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/channel.dart';
import 'channel_chat_screen.dart';
class ChannelsScreen extends StatefulWidget {
const ChannelsScreen({super.key});
@override
State<ChannelsScreen> createState() => _ChannelsScreenState();
}
class _ChannelsScreenState extends State<ChannelsScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MeshCoreConnector>().getChannels();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Channels'),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => context.read<MeshCoreConnector>().getChannels(),
),
],
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
if (connector.isLoadingChannels) {
return const Center(child: CircularProgressIndicator());
}
final channels = connector.channels;
if (channels.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.tag, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No channels configured',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => _addPublicChannel(context, connector),
icon: const Icon(Icons.public),
label: const Text('Add Public Channel'),
),
],
),
);
}
return ReorderableListView.builder(
padding: const EdgeInsets.all(8),
itemCount: channels.length,
onReorder: (oldIndex, newIndex) async {
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List<Channel>.from(channels);
final item = reordered.removeAt(oldIndex);
reordered.insert(newIndex, item);
await connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
);
},
itemBuilder: (context, index) {
final channel = channels[index];
return _buildChannelTile(context, connector, channel);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddChannelDialog(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildChannelTile(
BuildContext context,
MeshCoreConnector connector,
Channel channel,
) {
return Card(
key: ValueKey('channel_${channel.index}'),
child: ListTile(
leading: CircleAvatar(
backgroundColor: channel.isPublicChannel
? Colors.green.withValues(alpha: 0.2)
: Colors.blue.withValues(alpha: 0.2),
child: Icon(
channel.isPublicChannel
? Icons.public
: channel.name.startsWith('#')
? Icons.tag
: Icons.lock,
color: channel.isPublicChannel ? Colors.green : Colors.blue,
),
),
title: Text(
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
channel.name.startsWith('#')
? 'Hashtag channel'
: channel.isPublicChannel
? 'Public channel'
: 'Private channel',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _showEditChannelDialog(context, connector, channel),
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'delete') {
_confirmDeleteChannel(context, connector, channel);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'delete',
child: Text('Delete'),
),
],
),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(channel: channel),
),
),
),
);
}
void _showAddChannelDialog(BuildContext context) {
final connector = context.read<MeshCoreConnector>();
final nameController = TextEditingController();
final pskController = TextEditingController();
final maxChannels = connector.maxChannels;
int selectedIndex = _findNextAvailableIndex(connector.channels, maxChannels);
bool usePublicPsk = false;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Add Channel'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<int>(
initialValue: selectedIndex,
decoration: const InputDecoration(
labelText: 'Channel Index',
border: OutlineInputBorder(),
),
items: List.generate(maxChannels, (i) => i)
.map((i) => DropdownMenuItem(
value: i,
child: Text('Channel $i'),
))
.toList(),
onChanged: (value) {
if (value != null) {
setDialogState(() => selectedIndex = value);
}
},
),
const SizedBox(height: 16),
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Channel Name',
border: OutlineInputBorder(),
),
maxLength: 31,
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Use Public Channel'),
subtitle: const Text('Standard public PSK'),
value: usePublicPsk,
onChanged: (value) {
setDialogState(() {
usePublicPsk = value ?? false;
if (usePublicPsk) {
nameController.text = 'Public';
pskController.text = Channel.publicChannelPsk;
} else {
pskController.clear();
}
});
},
),
if (!usePublicPsk) ...[
const SizedBox(height: 8),
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Base64)',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
tooltip: 'Generate random PSK',
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
for (int i = 0; i < 16; i++) {
bytes[i] = random.nextInt(256);
}
pskController.text = base64Encode(bytes);
},
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = nameController.text.trim();
final pskBase64 = usePublicPsk
? Channel.publicChannelPsk
: pskController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a channel name')),
);
return;
}
Uint8List psk;
try {
final decoded = base64Decode(pskBase64);
psk = Uint8List(16);
for (int i = 0; i < decoded.length && i < 16; i++) {
psk[i] = decoded[i];
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid PSK format')),
);
return;
}
Navigator.pop(context);
connector.setChannel(selectedIndex, name, psk);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "$name" added')),
);
},
child: const Text('Add'),
),
],
),
),
);
}
void _showEditChannelDialog(
BuildContext context,
MeshCoreConnector connector,
Channel channel,
) {
final nameController = TextEditingController(text: channel.name);
final pskController = TextEditingController(text: channel.pskBase64);
bool smazEnabled = connector.isChannelSmazEnabled(channel.index);
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text('Edit Channel ${channel.index}'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Channel Name',
border: OutlineInputBorder(),
),
maxLength: 31,
),
const SizedBox(height: 16),
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Base64)',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
tooltip: 'Generate random PSK',
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
for (int i = 0; i < 16; i++) {
bytes[i] = random.nextInt(256);
}
pskController.text = base64Encode(bytes);
},
),
),
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('SMAZ compression'),
value: smazEnabled,
onChanged: (value) => setState(() => smazEnabled = value),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = nameController.text.trim();
final pskBase64 = pskController.text.trim();
Uint8List psk;
try {
final decoded = base64Decode(pskBase64);
psk = Uint8List(16);
for (int i = 0; i < decoded.length && i < 16; i++) {
psk[i] = decoded[i];
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid PSK format')),
);
return;
}
Navigator.pop(context);
connector.setChannel(channel.index, name, psk);
connector.setChannelSmazEnabled(channel.index, smazEnabled);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "$name" updated')),
);
},
child: const Text('Save'),
),
],
),
),
);
}
void _confirmDeleteChannel(
BuildContext context,
MeshCoreConnector connector,
Channel channel,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Channel'),
content: Text('Delete "${channel.name}"? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
connector.deleteChannel(channel.index);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "${channel.name}" deleted')),
);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
final psk = Uint8List(16);
final decoded = base64Decode(Channel.publicChannelPsk);
for (int i = 0; i < decoded.length && i < 16; i++) {
psk[i] = decoded[i];
}
connector.setChannel(0, 'Public', psk);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Public channel added')),
);
}
int _findNextAvailableIndex(List<Channel> channels, int maxChannels) {
final usedIndices = channels.map((c) => c.index).toSet();
for (int i = 0; i < maxChannels; i++) {
if (!usedIndices.contains(i)) return i;
}
return 0;
}
}
File diff suppressed because it is too large Load Diff
+791
View File
@@ -0,0 +1,791 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../storage/contact_group_store.dart';
import '../widgets/repeater_login_dialog.dart';
import '../utils/emoji_utils.dart';
import 'chat_screen.dart';
import 'repeater_hub_screen.dart';
enum ContactSortOption {
lastSeen,
recentMessages,
name,
type,
}
class ContactsScreen extends StatefulWidget {
const ContactsScreen({super.key});
@override
State<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends State<ContactsScreen> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
ContactSortOption _sortOption = ContactSortOption.lastSeen;
final ContactGroupStore _groupStore = ContactGroupStore();
List<ContactGroup> _groups = [];
@override
void initState() {
super.initState();
_loadGroups();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadGroups() async {
final groups = await _groupStore.loadGroups();
if (!mounted) return;
setState(() {
_groups = groups;
});
}
Future<void> _saveGroups() async {
await _groupStore.saveGroups(_groups);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Contacts'),
centerTitle: true,
actions: [
PopupMenuButton<ContactSortOption>(
icon: const Icon(Icons.sort),
tooltip: 'Sort by',
onSelected: (option) {
setState(() {
_sortOption = option;
});
},
itemBuilder: (context) => [
PopupMenuItem(
value: ContactSortOption.lastSeen,
child: Row(
children: [
Icon(
Icons.access_time,
size: 20,
color: _sortOption == ContactSortOption.lastSeen
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Last Seen',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.lastSeen
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: ContactSortOption.recentMessages,
child: Row(
children: [
Icon(
Icons.chat_bubble,
size: 20,
color: _sortOption == ContactSortOption.recentMessages
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Recent Messages',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.recentMessages
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: ContactSortOption.name,
child: Row(
children: [
Icon(
Icons.sort_by_alpha,
size: 20,
color: _sortOption == ContactSortOption.name
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Name',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.name
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: ContactSortOption.type,
child: Row(
children: [
Icon(
Icons.category,
size: 20,
color: _sortOption == ContactSortOption.type
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Type',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.type
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.group_add),
tooltip: 'New group',
onPressed: () {
final contacts = context.read<MeshCoreConnector>().contacts;
_showGroupEditor(context, contacts);
},
),
Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return IconButton(
icon: connector.isLoadingContacts
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: connector.isLoadingContacts
? null
: () => connector.getContacts(),
);
},
),
],
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final contacts = connector.contacts;
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (contacts.isEmpty && _groups.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No contacts yet',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Contacts will appear when devices advertise',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
final filteredGroups = _filterAndSortGroups(_groups, contacts);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search contacts...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onChanged: (value) {
setState(() {
_searchQuery = value.toLowerCase();
});
},
),
),
Expanded(
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No contacts or groups found',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
)
: RefreshIndicator(
onRefresh: () => connector.getContacts(),
child: ListView.builder(
itemCount: filteredGroups.length + filteredAndSorted.length,
itemBuilder: (context, index) {
if (index < filteredGroups.length) {
final group = filteredGroups[index];
return _buildGroupTile(context, group, contacts);
}
final contact = filteredAndSorted[index - filteredGroups.length];
return _ContactTile(
contact: contact,
onTap: () => _openChat(context, contact),
onLongPress: () => _showContactOptions(context, connector, contact),
);
},
),
),
),
],
);
},
),
);
}
List<ContactGroup> _filterAndSortGroups(List<ContactGroup> groups, List<Contact> contacts) {
final query = _searchQuery.trim().toLowerCase();
final contactNames = <String, String>{};
for (final contact in contacts) {
contactNames[contact.publicKeyHex] = contact.name.toLowerCase();
}
final filtered = groups.where((group) {
if (query.isEmpty) return true;
if (group.name.toLowerCase().contains(query)) return true;
for (final key in group.memberKeys) {
final name = contactNames[key];
if (name != null && name.contains(query)) return true;
}
return false;
}).toList();
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return filtered;
}
List<Contact> _filterAndSortContacts(List<Contact> contacts, MeshCoreConnector connector) {
var filtered = contacts.where((contact) {
if (_searchQuery.isEmpty) return true;
return contact.name.toLowerCase().contains(_searchQuery);
}).toList();
switch (_sortOption) {
case ContactSortOption.lastSeen:
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
break;
case ContactSortOption.recentMessages:
filtered.sort((a, b) {
final aMessages = connector.getMessages(a);
final bMessages = connector.getMessages(b);
final aLastMsg = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
final bLastMsg = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
return bLastMsg.compareTo(aLastMsg);
});
break;
case ContactSortOption.name:
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
break;
case ContactSortOption.type:
filtered.sort((a, b) {
final typeCompare = a.type.compareTo(b.type);
if (typeCompare != 0) return typeCompare;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
break;
}
return filtered;
}
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
final memberContacts = _resolveGroupContacts(group, contacts);
final subtitle = _formatGroupMembers(memberContacts);
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.group, color: Colors.white, size: 20),
),
title: Text(group.name),
subtitle: Text(subtitle),
trailing: Text(
memberContacts.length.toString(),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
onTap: () => _showGroupOptions(context, group, contacts),
onLongPress: () => _showGroupOptions(context, group, contacts),
);
}
List<Contact> _resolveGroupContacts(ContactGroup group, List<Contact> contacts) {
final byKey = <String, Contact>{};
for (final contact in contacts) {
byKey[contact.publicKeyHex] = contact;
}
final resolved = <Contact>[];
for (final key in group.memberKeys) {
final contact = byKey[key];
if (contact != null) {
resolved.add(contact);
}
}
resolved.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return resolved;
}
String _formatGroupMembers(List<Contact> members) {
if (members.isEmpty) return 'No members';
final names = members.map((c) => c.name).toList();
if (names.length <= 2) return names.join(', ');
return '${names.take(2).join(', ')} +${names.length - 2}';
}
void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater
if (contact.type == advTypeRepeater) {
_showRepeaterLogin(context, contact);
} else {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
);
}
}
void _showRepeaterLogin(BuildContext context, Contact repeater) {
showDialog(
context: context,
builder: (context) => RepeaterLoginDialog(
repeater: repeater,
onLogin: (password) {
// Navigate to repeater hub screen after successful login
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepeaterHubScreen(
repeater: repeater,
password: password,
),
),
);
},
),
);
}
void _showGroupOptions(BuildContext context, ContactGroup group, List<Contact> contacts) {
final members = _resolveGroupContacts(group, contacts);
showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit Group'),
onTap: () {
Navigator.pop(sheetContext);
_showGroupEditor(context, contacts, group: group);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Delete Group', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(sheetContext);
_confirmDeleteGroup(context, group);
},
),
if (members.isNotEmpty) const Divider(),
...members.map((member) {
return ListTile(
leading: const Icon(Icons.person),
title: Text(member.name),
subtitle: Text(member.typeLabel),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, member);
},
);
}),
],
),
),
),
);
}
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete Group'),
content: Text('Remove "${group.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(dialogContext);
setState(() {
_groups.removeWhere((g) => g.name == group.name);
});
await _saveGroups();
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _showGroupEditor(
BuildContext context,
List<Contact> contacts, {
ContactGroup? group,
}) {
final isEditing = group != null;
final nameController = TextEditingController(text: group?.name ?? '');
final selectedKeys = <String>{...group?.memberKeys ?? []};
String filterQuery = '';
final sortedContacts = List<Contact>.from(contacts)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (builderContext, setDialogState) {
final filteredContacts = filterQuery.isEmpty
? sortedContacts
: sortedContacts
.where((contact) => contact.name.toLowerCase().contains(filterQuery))
.toList();
return AlertDialog(
title: Text(isEditing ? 'Edit Group' : 'New Group'),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Group name',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
hintText: 'Filter contacts...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
isDense: true,
),
onChanged: (value) {
setDialogState(() {
filterQuery = value.toLowerCase();
});
},
),
const SizedBox(height: 12),
SizedBox(
height: 240,
child: filteredContacts.isEmpty
? const Center(child: Text('No contacts match your filter'))
: ListView.builder(
itemCount: filteredContacts.length,
itemBuilder: (context, index) {
final contact = filteredContacts[index];
final isSelected = selectedKeys.contains(contact.publicKeyHex);
return CheckboxListTile(
value: isSelected,
title: Text(contact.name),
subtitle: Text(contact.typeLabel),
onChanged: (value) {
setDialogState(() {
if (value == true) {
selectedKeys.add(contact.publicKeyHex);
} else {
selectedKeys.remove(contact.publicKeyHex);
}
});
},
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Group name is required')),
);
return;
}
final exists = _groups.any((g) {
if (isEditing && g.name == group!.name) return false;
return g.name.toLowerCase() == name.toLowerCase();
});
if (exists) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Group "$name" already exists')),
);
return;
}
setState(() {
if (isEditing) {
final index = _groups.indexWhere((g) => g.name == group!.name);
if (index != -1) {
_groups[index] = ContactGroup(
name: name,
memberKeys: selectedKeys.toList(),
);
}
} else {
_groups.add(ContactGroup(name: name, memberKeys: selectedKeys.toList()));
}
});
await _saveGroups();
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
},
child: Text(isEditing ? 'Save' : 'Create'),
),
],
);
},
),
);
}
void _showContactOptions(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
) {
final isRepeater = contact.type == advTypeRepeater;
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isRepeater)
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
title: const Text('Manage Repeater'),
onTap: () {
Navigator.pop(context);
_showRepeaterLogin(context, contact);
},
)
else
ListTile(
leading: const Icon(Icons.chat),
title: const Text('Open Chat'),
onTap: () {
Navigator.pop(context);
_openChat(context, contact);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Delete Contact', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
_confirmDelete(context, connector, contact);
},
),
],
),
),
);
}
void _confirmDelete(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Contact'),
content: Text('Remove ${contact.name} from contacts?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
connector.removeContact(contact);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}
class _ContactTile extends StatelessWidget {
final Contact contact;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _ContactTile({
required this.contact,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
child: _buildContactAvatar(contact),
),
title: Text(contact.name),
subtitle: Text('${contact.typeLabel}${contact.pathLabel}'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatLastSeen(contact.lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
],
),
onTap: onTap,
onLongPress: onLongPress,
);
}
Widget _buildContactAvatar(Contact contact) {
final emoji = firstEmoji(contact.name);
if (emoji != null) {
return Text(
emoji,
style: const TextStyle(fontSize: 18),
);
}
return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20);
}
IconData _getTypeIcon(int type) {
switch (type) {
case advTypeChat:
return Icons.chat;
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.group;
case advTypeSensor:
return Icons.sensors;
default:
return Icons.device_unknown;
}
}
Color _getTypeColor(int type) {
switch (type) {
case advTypeChat:
return Colors.blue;
case advTypeRepeater:
return Colors.orange;
case advTypeRoom:
return Colors.purple;
case advTypeSensor:
return Colors.green;
default:
return Colors.grey;
}
}
String _formatLastSeen(DateTime lastSeen) {
final now = DateTime.now();
final diff = now.difference(lastSeen);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${lastSeen.month}/${lastSeen.day}';
}
}
+292
View File
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import 'channels_screen.dart';
import 'contacts_screen.dart';
import 'map_screen.dart';
import 'settings_screen.dart';
/// Main hub screen after connecting to a MeshCore device
class DeviceScreen extends StatefulWidget {
const DeviceScreen({super.key});
@override
State<DeviceScreen> createState() => _DeviceScreenState();
}
class _DeviceScreenState extends State<DeviceScreen> {
bool _showBatteryVoltage = false;
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
// If disconnected, pop back to scanner
if (!connector.isConnected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
Navigator.popUntil(context, (route) => route.isFirst);
}
});
}
return PopScope(
canPop: false,
child: Scaffold(
appBar: AppBar(
title: Text(connector.device?.platformName ?? 'MeshCore Device'),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context, connector),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Connection status card
_buildStatusCard(connector, context),
const SizedBox(height: 24),
// Navigation grid
Expanded(
child: _buildNavigationGrid(context),
),
],
),
),
),
);
},
);
}
Widget _buildStatusCard(MeshCoreConnector connector, BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.bluetooth_connected, color: Colors.green, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connector.device?.platformName ?? 'Unknown Device',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
connector.device?.remoteId.toString() ?? '',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'Connected',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 8),
_buildBatteryIndicator(connector, context),
],
),
],
),
),
);
}
Widget _buildBatteryIndicator(MeshCoreConnector connector, BuildContext context) {
final percent = connector.batteryPercent;
final millivolts = connector.batteryMillivolts;
final percentLabel = percent != null ? '$percent%' : '--%';
final voltageLabel = millivolts == null
? '-- V'
: '${(millivolts / 1000.0).toStringAsFixed(2)} V';
final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel;
final icon = _batteryIcon(percent);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: Colors.grey[700]),
const SizedBox(width: 4),
Text(
displayLabel,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
IconData _batteryIcon(int? percent) {
if (percent == null) return Icons.battery_unknown;
if (percent <= 15) return Icons.battery_alert;
return Icons.battery_full;
}
Widget _buildNavigationGrid(BuildContext context) {
final items = [
_NavItem(
icon: Icons.people_outline,
label: 'Contacts',
color: Colors.blue,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ContactsScreen()),
),
),
_NavItem(
icon: Icons.tag,
label: 'Channels',
color: Colors.green,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ChannelsScreen()),
),
),
_NavItem(
icon: Icons.map_outlined,
label: 'Map',
color: Colors.orange,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MapScreen()),
),
),
_NavItem(
icon: Icons.settings_outlined,
label: 'Settings',
color: Colors.grey,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
),
];
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return _buildNavCard(item);
},
);
}
Widget _buildNavCard(_NavItem item) {
return Card(
child: InkWell(
onTap: item.onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
item.icon,
size: 48,
color: item.color,
),
const SizedBox(height: 12),
Text(
item.label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Future<void> _disconnect(BuildContext context, MeshCoreConnector connector) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure you want to disconnect from this device?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'),
),
],
),
);
if (confirmed == true) {
await connector.disconnect();
}
}
}
class _NavItem {
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
_NavItem({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
}
File diff suppressed because it is too large Load Diff
+528
View File
@@ -0,0 +1,528 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../widgets/debug_frame_viewer.dart';
import '../services/repeater_command_service.dart';
class RepeaterCliScreen extends StatefulWidget {
final Contact repeater;
final String password;
const RepeaterCliScreen({
super.key,
required this.repeater,
required this.password,
});
@override
State<RepeaterCliScreen> createState() => _RepeaterCliScreenState();
}
class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
final TextEditingController _commandController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<Map<String, String>> _commandHistory = [];
int _historyIndex = -1;
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
// Common commands for quick access
final List<Map<String, String>> _quickCommands = [
{'label': 'Get Name', 'command': 'get name'},
{'label': 'Get Radio', 'command': 'get radio'},
{'label': 'Get TX', 'command': 'get tx'},
{'label': 'Neighbors', 'command': 'neighbors'},
{'label': 'Version', 'command': 'ver'},
{'label': 'Advertise', 'command': 'advert'},
{'label': 'Clock', 'command': 'clock'},
];
@override
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector);
_setupMessageListener();
}
@override
void dispose() {
_frameSubscription?.cancel();
_commandService?.dispose();
_commandController.dispose();
_scrollController.dispose();
super.dispose();
}
void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == respCodeContactMsgRecv ||
frame[0] == respCodeContactMsgRecvV3) {
_handleTextMessageResponse(frame);
}
});
}
void _handleTextMessageResponse(Uint8List frame) {
final parsed = parseContactMessageText(frame);
if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text);
// Note: The command service will handle the response via the Future
// We don't need to add it to history here anymore as _sendCommand will do it
}
bool _matchesRepeaterPrefix(Uint8List prefix) {
final target = widget.repeater.publicKey;
if (target.length < 6 || prefix.length < 6) return false;
for (int i = 0; i < 6; i++) {
if (prefix[i] != target[i]) return false;
}
return true;
}
void _sendCommand({bool showDebug = false}) async {
final command = _commandController.text.trim();
if (command.isEmpty) return;
setState(() {
_commandHistory.add({
'type': 'command',
'text': command,
'timestamp': DateTime.now().toString(),
});
});
// Show debug info if requested
if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
DebugFrameViewer.showFrameDebug(context, frame, 'CLI Command Frame');
}
// Send CLI command to repeater with retry
try {
if (_commandService != null) {
final response = await _commandService!.sendCommand(
widget.repeater,
command,
);
if (mounted) {
setState(() {
_commandHistory.add({
'type': 'response',
'text': response,
'timestamp': DateTime.now().toString(),
});
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_commandHistory.add({
'type': 'response',
'text': 'Error: $e',
'timestamp': DateTime.now().toString(),
});
});
}
}
_commandController.clear();
_historyIndex = -1;
// Auto-scroll to bottom
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
void _useQuickCommand(String command) {
_commandController.text = command;
_sendCommand();
}
void _navigateHistory(bool up) {
final commands = _commandHistory
.where((entry) => entry['type'] == 'command')
.toList()
.reversed
.toList();
if (commands.isEmpty) return;
if (up) {
if (_historyIndex < commands.length - 1) {
_historyIndex++;
}
} else {
if (_historyIndex > 0) {
_historyIndex--;
} else {
_historyIndex = -1;
_commandController.clear();
return;
}
}
if (_historyIndex >= 0 && _historyIndex < commands.length) {
_commandController.text = commands[_historyIndex]['text'] ?? '';
_commandController.selection = TextSelection.fromPosition(
TextPosition(offset: _commandController.text.length),
);
}
}
void _clearHistory() {
setState(() {
_commandHistory.clear();
_historyIndex = -1;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater CLI'),
Text(
widget.repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
),
],
),
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: 'Debug Next Command',
onPressed: () {
// Set a flag or just send next command with debug
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter a command first')),
);
}
},
),
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: 'Command Help',
onPressed: () => _showCommandHelp(context),
),
IconButton(
icon: const Icon(Icons.clear_all),
tooltip: 'Clear History',
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
),
],
),
body: Column(
children: [
_buildQuickCommandsBar(),
const Divider(height: 1),
Expanded(
child: _commandHistory.isEmpty
? _buildEmptyState()
: _buildCommandHistory(),
),
const Divider(height: 1),
_buildCommandInput(),
],
),
);
}
Widget _buildQuickCommandsBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _quickCommands.map((cmd) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
label: Text(cmd['label']!),
onPressed: () => _useQuickCommand(cmd['command']!),
avatar: const Icon(Icons.play_arrow, size: 16),
),
);
}).toList(),
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No commands sent yet',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Type a command below or use quick commands',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
Widget _buildCommandHistory() {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _commandHistory.length,
itemBuilder: (context, index) {
final entry = _commandHistory[index];
final isCommand = entry['type'] == 'command';
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: isCommand
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isCommand ? Icons.chevron_right : Icons.arrow_back,
size: 16,
color: isCommand
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
entry['text']!,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: isCommand
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
],
),
);
},
);
}
Widget _buildCommandInput() {
return Container(
padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_upward, size: 20),
tooltip: 'Previous command',
onPressed: () => _navigateHistory(true),
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 20),
tooltip: 'Next command',
onPressed: () => _navigateHistory(false),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _commandController,
decoration: const InputDecoration(
hintText: 'Enter command...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
prefixText: '> ',
),
style: const TextStyle(fontFamily: 'monospace'),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendCommand(),
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _sendCommand,
),
],
),
),
);
}
void _showCommandHelp(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Commands List'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'NOTE: for the various "set ..." commands, there is also a "get ..." command.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
_buildHelpSection('General', [
'advert - Sends an advertisement packet',
"reboot - Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
"clock - Displays current time per device's clock.",
'password {new-password} - Sets a new admin password for the device.',
'ver - Shows the device version and firmware build date.',
'clear stats - Resets various stats counters to zero.',
]),
const SizedBox(height: 16),
_buildHelpSection('Settings', [
'set af {air-time-factor} - Sets the air-time-factor.',
'set tx {tx-power-dbm} - Sets LoRa transmit power in dBm. (reboot to apply)',
'set repeat {on|off} - Enables or disables the repeater role for this node.',
"set allow.read.only {on|off} - (Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
'set flood.max {max-hops} - Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
'set int.thresh {db} - Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
'set agc.reset.interval {seconds} - Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
"set multi.acks {0|1} - Enables or disables the 'double ACKs' feature.",
'set advert.interval {minutes} - Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
'set flood.advert.interval {hours} - Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
'set guest.password {guess-password} - Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
'set name {name} - Sets the advertisement name.',
'set lat {latitude} - Sets the advertisement map latitude. (decimal degrees)',
'set lon {longitude} - Sets the advertisement map longitude. (decimal degrees)',
'set radio {freq},{bw},{sf},{cr} - Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
'set rxdelay {base} - Sets (experimental) base (must be > 1 for effect) for applying slight delay to received packets, based on signal strength/score. Set to 0 to disable.',
'set txdelay {factor} - Sets a factor multiplied with time-on-air for a flood-mode packet and with a randomized slot system, to delay its forwarding. (to decrease likelihood of collisions)',
'set direct.txdelay {factor} - Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
'set bridge.enabled {on|off} - Enable/Disable bridge.',
'set bridge.delay {0-10000} - Set delay before retransmitting packets.',
'set bridge.source {rx|tx} - Choose wether the bridge will retransmit received packets or transmitted packets.',
'set bridge.baud {speed} - Set serial link baudrate for rs232 bridges.',
'set bridge.secret {shared-secret} - Set bridge secret for espnow bridges.',
'set adc.multiplier {factor} - Sets custom factor to adjust reported battery voltage (only supported on select boards).',
'tempradio {freq},{bw},{sf},{cr},{minutes} - Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
'setperm {pubkey-hex} {permissions} - Modifies the ACL. Removes matching entry (by pubkey prefix) if "permissions" is zero. Adds new entry if pubkey-hex is full length and is not currently in ACL. Updates entry by matching pubkey prefix. Permission bits vary per firmware role, but low 2 bits are: 0 (Guest), 1 (Read only), 2 (Read write), 3 (Admin)',
]),
const SizedBox(height: 16),
_buildHelpSection('Bridge', [
'get bridge.type - Gets bridge type none, rs232, espnow',
]),
const SizedBox(height: 16),
_buildHelpSection('Logging', [
'log start - Starts packet logging to file system.',
'log stop - Stops packet logging to file system.',
'log erase - Erases the packet logs from file system.',
]),
const SizedBox(height: 16),
_buildHelpSection('Neighbors (Repeater only)', [
'neighbors - Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
'neighbor.remove {pubkey-prefix} - Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
]),
const SizedBox(height: 16),
_buildHelpSection('Region Management (Repeater only)', [
'region commands have been introduced to manage region definitions and permissions.',
'region - (serial only) Lists all defined regions and current flood permissions.',
'region load - NOTE: this is a special multi-command invocation. Each subsequent command is a region name (indented with spaces to indicate parent hierarchy, with one space at minimum). Terminated by sending a blank line/command.',
"region get {* | name-prefix} - Searches for region with given name prefix (or '*' for the global scope). Replies with \"-> {region-name} ({parent-name}) {'F'}\"",
'region put {name} {* | parent-name-prefix} - Adds or updates a region definition with given name.',
'region remove {name} - Removes a region definition with given name. (must match exactly, and have no child regions)',
"region allowf {* | name-prefix} - Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
"region denyf {* | name-prefix} - Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
"region home - Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
"region home {* | name-prefix} - Sets the 'home' region.",
'region save - Persists the region list/map to storage.',
]),
const SizedBox(height: 16),
_buildHelpSection('GPS Management', [
'gps command has been introduced to manage location related topics.',
'gps - Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
'gps {on|off} - Toggles gps power state.',
'gps sync - Syncs node time with gps clock.',
"gps setloc - Sets node's position to gps coordinates and save preferences.",
'gps advert - Gives location advert configuration of the node:',
"none: don't include location in adverts",
'share: share gps location (from SensorManager)',
'prefs: advert the location stored in preferences',
'gps advert {none|share|prefs} - Sets location advert configuration.',
]),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildHelpSection(String title, List<String> commands) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
...commands.map((cmd) => Padding(
padding: const EdgeInsets.only(left: 8, bottom: 4),
child: Text(
'$cmd',
style: const TextStyle(fontSize: 13, fontFamily: 'monospace'),
),
)),
],
);
}
}
+204
View File
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import '../models/contact.dart';
import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart';
import 'repeater_settings_screen.dart';
class RepeaterHubScreen extends StatelessWidget {
final Contact repeater;
final String password;
const RepeaterHubScreen({
super.key,
required this.repeater,
required this.password,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Management'),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
),
],
),
centerTitle: false,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Repeater info card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
CircleAvatar(
radius: 40,
backgroundColor: Colors.orange,
child: const Icon(Icons.cell_tower, size: 40, color: Colors.white),
),
const SizedBox(height: 16),
Text(
repeater.name,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
repeater.pathLabel,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
if (repeater.hasLocation) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_on, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
],
],
),
),
),
const SizedBox(height: 24),
const Text(
'Management Tools',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Status button
_buildManagementCard(
context,
icon: Icons.analytics,
title: 'Status',
subtitle: 'View repeater status, stats, and neighbors',
color: Colors.blue,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepeaterStatusScreen(
repeater: repeater,
password: password,
),
),
);
},
),
const SizedBox(height: 12),
// CLI button
_buildManagementCard(
context,
icon: Icons.terminal,
title: 'CLI',
subtitle: 'Send commands to the repeater',
color: Colors.green,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepeaterCliScreen(
repeater: repeater,
password: password,
),
),
);
},
),
const SizedBox(height: 12),
// Settings button
_buildManagementCard(
context,
icon: Icons.settings,
title: 'Settings',
subtitle: 'Configure repeater parameters',
color: Colors.orange,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepeaterSettingsScreen(
repeater: repeater,
password: password,
),
),
);
},
),
],
),
),
);
}
Widget _buildManagementCard(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required Color color,
required VoidCallback onTap,
}) {
return Card(
elevation: 2,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 32),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
Icon(Icons.chevron_right, color: Colors.grey[400]),
],
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart';
class RepeaterStatusScreen extends StatefulWidget {
final Contact repeater;
final String password;
const RepeaterStatusScreen({
super.key,
required this.repeater,
required this.password,
});
@override
State<RepeaterStatusScreen> createState() => _RepeaterStatusScreenState();
}
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
bool _isLoading = false;
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
Timer? _statusTimeout;
DateTime? _statusRequestedAt;
int? _batteryMv;
int? _uptimeSecs;
int? _queueLen;
int? _debugFlags;
int? _lastRssi;
double? _lastSnr;
int? _noiseFloor;
int? _txAirSecs;
int? _rxAirSecs;
int? _packetsSent;
int? _packetsRecv;
int? _floodTx;
int? _directTx;
int? _floodRx;
int? _directRx;
int? _dupFlood;
int? _dupDirect;
@override
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector);
_setupMessageListener();
_loadStatus();
}
@override
void dispose() {
_frameSubscription?.cancel();
_commandService?.dispose();
_statusTimeout?.cancel();
super.dispose();
}
void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == pushCodeStatusResponse) {
_handleStatusResponse(frame);
} else if (frame[0] == respCodeContactMsgRecv ||
frame[0] == respCodeContactMsgRecvV3) {
_handleTextMessageResponse(frame);
}
});
}
void _handleTextMessageResponse(Uint8List frame) {
final parsed = parseContactMessageText(frame);
if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text);
// Parse status responses
_parseStatusResponse(parsed.text);
}
void _handleStatusResponse(Uint8List frame) {
if (frame.length < 8) return;
final prefix = frame.sublist(2, 8);
if (!_matchesRepeaterPrefix(prefix)) return;
const payloadOffset = 8;
const statsSize = 52;
if (frame.length < payloadOffset + statsSize) return;
final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize);
int offset = 0;
final batteryMv = data.getUint16(offset, Endian.little);
offset += 2;
final queueLen = data.getUint16(offset, Endian.little);
offset += 2;
final noiseFloor = data.getInt16(offset, Endian.little);
offset += 2;
final lastRssi = data.getInt16(offset, Endian.little);
offset += 2;
final packetsRecv = data.getUint32(offset, Endian.little);
offset += 4;
final packetsSent = data.getUint32(offset, Endian.little);
offset += 4;
final txAirSecs = data.getUint32(offset, Endian.little);
offset += 4;
final uptimeSecs = data.getUint32(offset, Endian.little);
offset += 4;
final floodTx = data.getUint32(offset, Endian.little);
offset += 4;
final directTx = data.getUint32(offset, Endian.little);
offset += 4;
final floodRx = data.getUint32(offset, Endian.little);
offset += 4;
final directRx = data.getUint32(offset, Endian.little);
offset += 4;
final errEvents = data.getUint16(offset, Endian.little);
offset += 2;
final lastSnrRaw = data.getInt16(offset, Endian.little);
offset += 2;
final directDups = data.getUint16(offset, Endian.little);
offset += 2;
final floodDups = data.getUint16(offset, Endian.little);
offset += 2;
final rxAirSecs = data.getUint32(offset, Endian.little);
_statusTimeout?.cancel();
if (!mounted) return;
setState(() {
_isLoading = false;
_batteryMv = batteryMv;
_queueLen = queueLen;
_noiseFloor = noiseFloor;
_lastRssi = lastRssi;
_packetsRecv = packetsRecv;
_packetsSent = packetsSent;
_txAirSecs = txAirSecs;
_rxAirSecs = rxAirSecs;
_uptimeSecs = uptimeSecs;
_floodTx = floodTx;
_directTx = directTx;
_floodRx = floodRx;
_directRx = directRx;
_debugFlags = errEvents;
_lastSnr = lastSnrRaw / 4.0;
_dupDirect = directDups;
_dupFlood = floodDups;
});
}
bool _matchesRepeaterPrefix(Uint8List prefix) {
final target = widget.repeater.publicKey;
if (target.length < 6 || prefix.length < 6) return false;
for (int i = 0; i < 6; i++) {
if (prefix[i] != target[i]) return false;
}
return true;
}
void _parseStatusResponse(String response) {
final trimmed = response.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
final data = jsonDecode(trimmed) as Map<String, dynamic>;
if (data.containsKey('battery_mv')) {
_batteryMv = _asInt(data['battery_mv']);
_uptimeSecs = _asInt(data['uptime_secs']);
_queueLen = _asInt(data['queue_len']);
_debugFlags = _asInt(data['errors']);
} else if (data.containsKey('noise_floor')) {
_noiseFloor = _asInt(data['noise_floor']);
_lastRssi = _asInt(data['last_rssi']);
_lastSnr = _asDouble(data['last_snr']);
_txAirSecs = _asInt(data['tx_air_secs']);
_rxAirSecs = _asInt(data['rx_air_secs']);
} else if (data.containsKey('recv') || data.containsKey('sent')) {
_packetsRecv = _asInt(data['recv']);
_packetsSent = _asInt(data['sent']);
_floodTx = _asInt(data['flood_tx']);
_directTx = _asInt(data['direct_tx']);
_floodRx = _asInt(data['flood_rx']);
_directRx = _asInt(data['direct_rx']);
_dupFlood = _asInt(data['dup_flood']);
_dupDirect = _asInt(data['dup_direct']);
}
} catch (_) {
// Ignore parse failures for non-JSON responses.
}
}
if (mounted) {
setState(() {});
}
}
Future<void> _loadStatus() async {
if (_commandService == null) return;
setState(() {
_isLoading = true;
_statusRequestedAt = DateTime.now();
_batteryMv = null;
_uptimeSecs = null;
_queueLen = null;
_debugFlags = null;
_lastRssi = null;
_lastSnr = null;
_noiseFloor = null;
_txAirSecs = null;
_rxAirSecs = null;
_packetsSent = null;
_packetsRecv = null;
_floodTx = null;
_directTx = null;
_floodRx = null;
_directRx = null;
_dupFlood = null;
_dupDirect = null;
});
try {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildSendStatusRequestFrame(widget.repeater.publicKey);
await connector.sendFrame(frame);
_statusTimeout?.cancel();
_statusTimeout = Timer(const Duration(seconds: 12), () {
if (!mounted) return;
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Status request timed out.'),
backgroundColor: Colors.red,
),
);
});
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading status: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Status'),
Text(
widget.repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
),
],
),
centerTitle: false,
actions: [
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus,
tooltip: 'Refresh',
),
],
),
body: RefreshIndicator(
onRefresh: _loadStatus,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSystemInfoCard(),
const SizedBox(height: 16),
_buildRadioStatsCard(),
const SizedBox(height: 16),
_buildPacketStatsCard(),
],
),
),
);
}
Widget _buildSystemInfoCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
const Text(
'System Information',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Battery', _batteryText()),
_buildInfoRow('Clock (at login)', _clockText()),
_buildInfoRow('Uptime', _formatDuration(_uptimeSecs)),
_buildInfoRow('Queue Length', _formatValue(_queueLen)),
_buildInfoRow('Debug Flags', _formatValue(_debugFlags)),
],
),
),
);
}
Widget _buildRadioStatsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.radio, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
const Text(
'Radio Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Last RSSI', _formatValue(_lastRssi, suffix: ' dB')),
_buildInfoRow('Last SNR', _formatSnr(_lastSnr)),
_buildInfoRow('Noise Floor', _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow('TX Airtime', _formatDuration(_txAirSecs)),
_buildInfoRow('RX Airtime', _formatDuration(_rxAirSecs)),
],
),
),
);
}
Widget _buildPacketStatsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.analytics, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
const Text(
'Packet Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Sent', _packetTxText()),
_buildInfoRow('Received', _packetRxText()),
_buildInfoRow('Duplicates', _duplicateText()),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
),
),
],
),
);
}
int? _asInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is double) return value.round();
return int.tryParse(value.toString());
}
double? _asDouble(dynamic value) {
if (value == null) return null;
if (value is double) return value;
if (value is int) return value.toDouble();
return double.tryParse(value.toString());
}
String _batteryText() {
if (_batteryMv == null) return '';
final percent = _batteryPercentFromMv(_batteryMv!);
final volts = (_batteryMv! / 1000.0).toStringAsFixed(2);
return '$percent% / ${volts}V';
}
int _batteryPercentFromMv(int millivolts) {
const minMv = 3000;
const maxMv = 4200;
if (millivolts <= minMv) return 0;
if (millivolts >= maxMv) return 100;
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
}
String _clockText() {
if (_statusRequestedAt == null) return '';
final dt = _statusRequestedAt!;
final date = '${dt.day}/${dt.month}/${dt.year}';
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
return '$date $time';
}
String _formatDuration(int? seconds) {
if (seconds == null) return '';
final days = seconds ~/ 86400;
final hours = (seconds % 86400) ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;
return '$days days ${hours}h ${minutes}m ${secs}s';
}
String _packetTxText() {
if (_packetsSent == null) return '';
final flood = _formatValue(_floodTx);
final direct = _formatValue(_directTx);
return 'Total: $_packetsSent, Flood: $flood, Direct: $direct';
}
String _packetRxText() {
if (_packetsRecv == null) return '';
final flood = _formatValue(_floodRx);
final direct = _formatValue(_directRx);
return 'Total: $_packetsRecv, Flood: $flood, Direct: $direct';
}
String _duplicateText() {
if (_dupFlood != null || _dupDirect != null) {
final flood = _formatValue(_dupFlood);
final direct = _formatValue(_dupDirect);
return 'Flood: $flood, Direct: $direct';
}
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '';
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
if (dupTotal < 0) return '';
return 'Total: $dupTotal';
}
String _formatValue(num? value, {String? suffix}) {
if (value == null) return '';
return suffix == null ? value.toString() : '$value$suffix';
}
String _formatSnr(double? snr) {
if (snr == null) return '';
return snr.toStringAsFixed(2);
}
}
+176
View File
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../widgets/device_tile.dart';
import 'device_screen.dart';
/// Screen for scanning and connecting to MeshCore devices
class ScannerScreen extends StatelessWidget {
const ScannerScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('MeshCore Open'),
centerTitle: true,
automaticallyImplyLeading: false,
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return Column(
children: [
// Status bar
_buildStatusBar(context, connector),
// Device list
Expanded(
child: _buildDeviceList(context, connector),
),
],
);
},
),
floatingActionButton: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isScanning = connector.state == MeshCoreConnectionState.scanning;
return FloatingActionButton.extended(
onPressed: () {
if (isScanning) {
connector.stopScan();
} else {
connector.startScan();
}
},
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.bluetooth_searching),
label: Text(isScanning ? 'Stop' : 'Scan'),
);
},
),
);
}
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
String statusText;
Color statusColor;
switch (connector.state) {
case MeshCoreConnectionState.scanning:
statusText = 'Scanning for devices...';
statusColor = Colors.blue;
break;
case MeshCoreConnectionState.connecting:
statusText = 'Connecting...';
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.connected:
statusText = 'Connected to ${connector.device?.platformName}';
statusColor = Colors.green;
break;
case MeshCoreConnectionState.disconnecting:
statusText = 'Disconnecting...';
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.disconnected:
statusText = 'Not connected';
statusColor = Colors.grey;
break;
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Text(
statusText,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
),
],
),
);
}
Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
if (connector.scanResults.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bluetooth,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
connector.state == MeshCoreConnectionState.scanning
? 'Searching for MeshCore devices...'
: 'Tap Scan to find MeshCore devices',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: connector.scanResults.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final result = connector.scanResults[index];
return DeviceTile(
scanResult: result,
onTap: () => _connectToDevice(context, connector, result),
);
},
);
}
Future<void> _connectToDevice(
BuildContext context,
MeshCoreConnector connector,
ScanResult result,
) async {
try {
await connector.connect(result.device);
if (context.mounted && connector.isConnected) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DeviceScreen(),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connection failed: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
+693
View File
@@ -0,0 +1,693 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../models/radio_settings.dart';
import '../services/app_settings_service.dart';
import 'app_settings_screen.dart';
import 'ble_debug_log_screen.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
centerTitle: true,
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildDeviceInfoCard(connector),
const SizedBox(height: 16),
_buildAppSettingsCard(context),
const SizedBox(height: 16),
_buildNodeSettingsCard(context, connector),
const SizedBox(height: 16),
_buildActionsCard(context, connector),
const SizedBox(height: 16),
_buildDebugCard(context),
const SizedBox(height: 16),
_buildAboutCard(context),
],
);
},
),
);
}
Widget _buildDeviceInfoCard(MeshCoreConnector connector) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Device Info',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildInfoRow('Name', connector.device?.platformName ?? 'Unknown'),
_buildInfoRow('ID', connector.device?.remoteId.toString() ?? 'Unknown'),
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
if (connector.selfName != null)
_buildInfoRow('Node Name', connector.selfName!),
if (connector.selfPublicKey != null)
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
],
),
),
);
}
Widget _buildAppSettingsCard(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('App Settings'),
subtitle: const Text('Notifications, messaging, and map preferences'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AppSettingsScreen()),
);
},
),
);
}
Widget _buildNodeSettingsCard(BuildContext context, MeshCoreConnector connector) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Node Settings',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('Node Name'),
subtitle: Text(connector.selfName ?? 'Not set'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editNodeName(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.radio),
title: const Text('Radio Settings'),
subtitle: const Text('Frequency, power, spreading factor'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRadioSettings(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.location_on_outlined),
title: const Text('Location'),
subtitle: const Text('GPS coordinates'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editLocation(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.visibility_off_outlined),
title: const Text('Privacy Mode'),
subtitle: const Text('Hide name/location in advertisements'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _togglePrivacy(context, connector),
),
],
),
);
}
Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Actions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.cell_tower),
title: const Text('Send Advertisement'),
subtitle: const Text('Broadcast presence now'),
onTap: () => _sendAdvert(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.sync),
title: const Text('Sync Time'),
subtitle: const Text('Set device clock to phone time'),
onTap: () => _syncTime(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Refresh Contacts'),
subtitle: const Text('Reload contact list from device'),
onTap: () => connector.getContacts(),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.restart_alt, color: Colors.orange),
title: const Text('Reboot Device'),
subtitle: const Text('Restart the MeshCore device'),
onTap: () => _confirmReboot(context, connector),
),
],
),
);
}
Widget _buildAboutCard(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('About'),
subtitle: const Text('MeshCore Open v0.1.0'),
onTap: () => _showAbout(context),
),
);
}
Widget _buildDebugCard(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.bug_report_outlined),
title: const Text('BLE Debug Log'),
subtitle: const Text('Commands, responses, and status'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()),
);
},
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: Colors.grey[600])),
Flexible(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
final controller = TextEditingController(text: connector.selfName ?? '');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Node Name'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Enter node name',
border: OutlineInputBorder(),
),
maxLength: 31,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.sendCliCommand('set name ${controller.text}');
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Name updated')),
);
},
child: const Text('Save'),
),
],
),
);
}
void _showRadioSettings(BuildContext context, MeshCoreConnector connector) {
showDialog(
context: context,
builder: (context) => _RadioSettingsDialog(connector: connector),
);
}
void _editLocation(BuildContext context, MeshCoreConnector connector) {
final latController = TextEditingController();
final lonController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Location'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: latController,
decoration: const InputDecoration(
labelText: 'Latitude',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
),
const SizedBox(height: 16),
TextField(
controller: lonController,
decoration: const InputDecoration(
labelText: 'Longitude',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
var updated = false;
if (latController.text.isNotEmpty) {
await connector.sendCliCommand('set lat ${latController.text}');
updated = true;
}
if (lonController.text.isNotEmpty) {
await connector.sendCliCommand('set lon ${lonController.text}');
updated = true;
}
if (updated) {
await connector.refreshDeviceInfo();
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location updated')),
);
},
child: const Text('Save'),
),
],
),
);
}
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Privacy Mode'),
content: const Text('Toggle privacy mode to hide your name and location in advertisements.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.sendCliCommand('set privacy on');
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode enabled')),
);
},
child: const Text('Enable'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.sendCliCommand('set privacy off');
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode disabled')),
);
},
child: const Text('Disable'),
),
],
),
);
}
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
connector.sendCliCommand('advert');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Advertisement sent')),
);
}
void _syncTime(BuildContext context, MeshCoreConnector connector) {
connector.syncTime();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Time synchronized')),
);
}
void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reboot Device'),
content: const Text('Are you sure you want to reboot the device? You will be disconnected.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
connector.sendCliCommand('reboot');
},
child: const Text('Reboot', style: TextStyle(color: Colors.orange)),
),
],
),
);
}
void _showAbout(BuildContext context) {
showAboutDialog(
context: context,
applicationName: 'MeshCore Open',
applicationVersion: '0.1.0',
applicationLegalese: '2024 MeshCore Open Source Project',
children: [
const SizedBox(height: 16),
const Text(
'An open-source Flutter client for MeshCore LoRa mesh networking devices.',
),
],
);
}
}
class _RadioSettingsDialog extends StatefulWidget {
final MeshCoreConnector connector;
const _RadioSettingsDialog({required this.connector});
@override
State<_RadioSettingsDialog> createState() => _RadioSettingsDialogState();
}
class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
final _frequencyController = TextEditingController();
LoRaBandwidth _bandwidth = LoRaBandwidth.bw125;
LoRaSpreadingFactor _spreadingFactor = LoRaSpreadingFactor.sf7;
LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5;
final _txPowerController = TextEditingController(text: '20');
@override
void initState() {
super.initState();
// Populate with current settings if available
if (widget.connector.currentFreqHz != null) {
_frequencyController.text = (widget.connector.currentFreqHz! / 1000.0).toStringAsFixed(3);
} else {
_frequencyController.text = '915.0';
}
if (widget.connector.currentBwHz != null) {
// Find matching bandwidth enum
final bwValue = widget.connector.currentBwHz!;
for (var bw in LoRaBandwidth.values) {
if (bw.hz == bwValue) {
_bandwidth = bw;
break;
}
}
}
if (widget.connector.currentSf != null) {
// Find matching spreading factor enum
final sfValue = widget.connector.currentSf!;
for (var sf in LoRaSpreadingFactor.values) {
if (sf.value == sfValue) {
_spreadingFactor = sf;
break;
}
}
}
if (widget.connector.currentCr != null) {
// Find matching coding rate enum
final crValue = _toUiCodingRate(widget.connector.currentCr!);
for (var cr in LoRaCodingRate.values) {
if (cr.value == crValue) {
_codingRate = cr;
break;
}
}
}
if (widget.connector.currentTxPower != null) {
_txPowerController.text = widget.connector.currentTxPower.toString();
}
}
@override
void dispose() {
_frequencyController.dispose();
_txPowerController.dispose();
super.dispose();
}
void _applyPreset(RadioSettings preset) {
setState(() {
_frequencyController.text = preset.frequencyMHz.toString();
_bandwidth = preset.bandwidth;
_spreadingFactor = preset.spreadingFactor;
_codingRate = preset.codingRate;
_txPowerController.text = preset.txPowerDbm.toString();
});
}
Future<void> _saveSettings() async {
final freqMHz = double.tryParse(_frequencyController.text);
final txPower = int.tryParse(_txPowerController.text);
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid frequency (300-2500 MHz)')),
);
return;
}
if (txPower == null || txPower < 0 || txPower > 22) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid TX power (0-22 dBm)')),
);
return;
}
final freqHz = (freqMHz * 1000).round();
final bwHz = _bandwidth.hz;
final sf = _spreadingFactor.value;
final cr = _toDeviceCodingRate(_codingRate.value, widget.connector.currentCr);
try {
await widget.connector.sendFrame(buildSetRadioParamsFrame(freqHz, bwHz, sf, cr));
await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower));
await widget.connector.refreshDeviceInfo();
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Radio settings updated')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
int _toUiCodingRate(int deviceCr) {
return deviceCr <= 4 ? deviceCr + 4 : deviceCr;
}
int _toDeviceCodingRate(int uiCr, int? deviceCr) {
if (deviceCr != null && deviceCr <= 4) {
return uiCr - 4;
}
return uiCr;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Radio Settings'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Presets', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
_PresetChip(
label: '915 MHz',
onTap: () => _applyPreset(RadioSettings.preset915MHz),
),
_PresetChip(
label: '868 MHz',
onTap: () => _applyPreset(RadioSettings.preset868MHz),
),
_PresetChip(
label: '433 MHz',
onTap: () => _applyPreset(RadioSettings.preset433MHz),
),
_PresetChip(
label: 'Long Range',
onTap: () => _applyPreset(RadioSettings.presetLongRange),
),
_PresetChip(
label: 'Fast Speed',
onTap: () => _applyPreset(RadioSettings.presetFastSpeed),
),
],
),
const SizedBox(height: 24),
TextField(
controller: _frequencyController,
decoration: const InputDecoration(
labelText: 'Frequency (MHz)',
border: OutlineInputBorder(),
helperText: '300.0 - 2500.0',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaBandwidth>(
value: _bandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
),
items: LoRaBandwidth.values
.map((bw) => DropdownMenuItem(
value: bw,
child: Text(bw.label),
))
.toList(),
onChanged: (value) {
if (value != null) setState(() => _bandwidth = value);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaSpreadingFactor>(
value: _spreadingFactor,
decoration: const InputDecoration(
labelText: 'Spreading Factor',
border: OutlineInputBorder(),
),
items: LoRaSpreadingFactor.values
.map((sf) => DropdownMenuItem(
value: sf,
child: Text(sf.label),
))
.toList(),
onChanged: (value) {
if (value != null) setState(() => _spreadingFactor = value);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaCodingRate>(
value: _codingRate,
decoration: const InputDecoration(
labelText: 'Coding Rate',
border: OutlineInputBorder(),
),
items: LoRaCodingRate.values
.map((cr) => DropdownMenuItem(
value: cr,
child: Text(cr.label),
))
.toList(),
onChanged: (value) {
if (value != null) setState(() => _codingRate = value);
},
),
const SizedBox(height: 16),
TextField(
controller: _txPowerController,
decoration: const InputDecoration(
labelText: 'TX Power (dBm)',
border: OutlineInputBorder(),
helperText: '0 - 22',
),
keyboardType: TextInputType.number,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saveSettings,
child: const Text('Save'),
),
],
);
}
}
class _PresetChip extends StatelessWidget {
final String label;
final VoidCallback onTap;
const _PresetChip({required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
return ActionChip(
label: Text(label),
onPressed: onTap,
);
}
}
+101
View File
@@ -0,0 +1,101 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_settings.dart';
class AppSettingsService extends ChangeNotifier {
static const String _settingsKey = 'app_settings';
AppSettings _settings = AppSettings();
AppSettings get settings => _settings;
String batteryChemistryForDevice(String deviceId) {
final stored = _settings.batteryChemistryByDeviceId[deviceId];
if (stored == 'liion') return 'nmc';
return stored ?? 'nmc';
}
Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_settingsKey);
if (jsonStr != null) {
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
_settings = AppSettings.fromJson(json);
notifyListeners();
} catch (e) {
// If parsing fails, use defaults
_settings = AppSettings();
}
}
}
Future<void> updateSettings(AppSettings newSettings) async {
_settings = newSettings;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
final jsonStr = jsonEncode(_settings.toJson());
await prefs.setString(_settingsKey, jsonStr);
}
Future<void> setClearPathOnMaxRetry(bool value) async {
await updateSettings(_settings.copyWith(clearPathOnMaxRetry: value));
}
Future<void> setMapShowRepeaters(bool value) async {
await updateSettings(_settings.copyWith(mapShowRepeaters: value));
}
Future<void> setMapShowChatNodes(bool value) async {
await updateSettings(_settings.copyWith(mapShowChatNodes: value));
}
Future<void> setMapShowOtherNodes(bool value) async {
await updateSettings(_settings.copyWith(mapShowOtherNodes: value));
}
Future<void> setMapTimeFilterHours(double value) async {
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
}
Future<void> setMapKeyPrefixEnabled(bool value) async {
await updateSettings(_settings.copyWith(mapKeyPrefixEnabled: value));
}
Future<void> setMapKeyPrefix(String value) async {
await updateSettings(_settings.copyWith(mapKeyPrefix: value));
}
Future<void> setMapShowMarkers(bool value) async {
await updateSettings(_settings.copyWith(mapShowMarkers: value));
}
Future<void> setNotificationsEnabled(bool value) async {
await updateSettings(_settings.copyWith(notificationsEnabled: value));
}
Future<void> setNotifyOnNewMessage(bool value) async {
await updateSettings(_settings.copyWith(notifyOnNewMessage: value));
}
Future<void> setNotifyOnNewAdvert(bool value) async {
await updateSettings(_settings.copyWith(notifyOnNewAdvert: value));
}
Future<void> setAutoRouteRotationEnabled(bool value) async {
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
}
Future<void> setThemeMode(String value) async {
await updateSettings(_settings.copyWith(themeMode: value));
}
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
updated[deviceId] = chemistry;
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated));
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import '../connector/meshcore_protocol.dart';
class BleDebugLogEntry {
final DateTime timestamp;
final bool outgoing;
final String description;
final Uint8List payload;
BleDebugLogEntry({
required this.timestamp,
required this.outgoing,
required this.description,
required this.payload,
});
String get hexPreview {
const maxBytes = 64;
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
return payload.length > maxBytes ? '$hex' : hex;
}
}
class BleRawLogRxEntry {
final DateTime timestamp;
final Uint8List payload;
BleRawLogRxEntry({
required this.timestamp,
required this.payload,
});
String get hexPreview {
const maxBytes = 64;
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
return payload.length > maxBytes ? '$hex' : hex;
}
}
class BleDebugLogService extends ChangeNotifier {
static const int maxEntries = 500;
final List<BleDebugLogEntry> _entries = [];
final List<BleRawLogRxEntry> _rawLogRxEntries = [];
List<BleDebugLogEntry> get entries => List.unmodifiable(_entries);
List<BleRawLogRxEntry> get rawLogRxEntries => List.unmodifiable(_rawLogRxEntries);
void logFrame(Uint8List frame, {required bool outgoing, String? note}) {
if (frame.isEmpty) return;
final code = frame[0];
final description = _describeFrame(code, frame, outgoing, note);
_entries.add(
BleDebugLogEntry(
timestamp: DateTime.now(),
outgoing: outgoing,
description: description,
payload: Uint8List.fromList(frame),
),
);
if (_entries.length > maxEntries) {
_entries.removeRange(0, _entries.length - maxEntries);
}
if (!outgoing && code == pushCodeLogRxData && frame.length > 3) {
_rawLogRxEntries.add(
BleRawLogRxEntry(
timestamp: DateTime.now(),
payload: Uint8List.fromList(frame.sublist(3)),
),
);
if (_rawLogRxEntries.length > maxEntries) {
_rawLogRxEntries.removeRange(0, _rawLogRxEntries.length - maxEntries);
}
}
notifyListeners();
}
void clear() {
_entries.clear();
_rawLogRxEntries.clear();
notifyListeners();
}
String _describeFrame(int code, Uint8List frame, bool outgoing, String? note) {
final label = _codeLabel(code);
final prefix = outgoing ? 'TX' : 'RX';
final extra = _frameDetail(code, frame);
final noteText = note != null ? '$note' : '';
return '$prefix $label$extra$noteText';
}
String _codeLabel(int code) {
switch (code) {
case cmdAppStart:
return 'CMD_APP_START';
case cmdSendTxtMsg:
return 'CMD_SEND_TXT_MSG';
case cmdSendChannelTxtMsg:
return 'CMD_SEND_CHANNEL_TXT_MSG';
case cmdGetContacts:
return 'CMD_GET_CONTACTS';
case cmdGetDeviceTime:
return 'CMD_GET_DEVICE_TIME';
case cmdSetDeviceTime:
return 'CMD_SET_DEVICE_TIME';
case cmdSendSelfAdvert:
return 'CMD_SEND_SELF_ADVERT';
case cmdSetAdvertName:
return 'CMD_SET_ADVERT_NAME';
case cmdAddUpdateContact:
return 'CMD_ADD_UPDATE_CONTACT';
case cmdSyncNextMessage:
return 'CMD_SYNC_NEXT_MESSAGE';
case cmdSetRadioParams:
return 'CMD_SET_RADIO_PARAMS';
case cmdSetRadioTxPower:
return 'CMD_SET_RADIO_TX_POWER';
case cmdResetPath:
return 'CMD_RESET_PATH';
case cmdRemoveContact:
return 'CMD_REMOVE_CONTACT';
case cmdReboot:
return 'CMD_REBOOT';
case cmdGetBattAndStorage:
return 'CMD_GET_BATT_AND_STORAGE';
case cmdSendLogin:
return 'CMD_SEND_LOGIN';
case cmdGetChannel:
return 'CMD_GET_CHANNEL';
case cmdSetChannel:
return 'CMD_SET_CHANNEL';
case cmdGetRadioSettings:
return 'CMD_GET_RADIO_SETTINGS';
case respCodeOk:
return 'RESP_CODE_OK';
case respCodeErr:
return 'RESP_CODE_ERR';
case respCodeContactsStart:
return 'RESP_CODE_CONTACTS_START';
case respCodeContact:
return 'RESP_CODE_CONTACT';
case respCodeEndOfContacts:
return 'RESP_CODE_END_OF_CONTACTS';
case respCodeSelfInfo:
return 'RESP_CODE_SELF_INFO';
case respCodeSent:
return 'RESP_CODE_SENT';
case respCodeContactMsgRecv:
return 'RESP_CODE_CONTACT_MSG_RECV';
case respCodeChannelMsgRecv:
return 'RESP_CODE_CHANNEL_MSG_RECV';
case respCodeCurrTime:
return 'RESP_CODE_CURR_TIME';
case respCodeNoMoreMessages:
return 'RESP_CODE_NO_MORE_MESSAGES';
case respCodeBattAndStorage:
return 'RESP_CODE_BATT_AND_STORAGE';
case respCodeContactMsgRecvV3:
return 'RESP_CODE_CONTACT_MSG_RECV_V3';
case respCodeChannelMsgRecvV3:
return 'RESP_CODE_CHANNEL_MSG_RECV_V3';
case respCodeChannelInfo:
return 'RESP_CODE_CHANNEL_INFO';
case respCodeRadioSettings:
return 'RESP_CODE_RADIO_SETTINGS';
case pushCodeAdvert:
return 'PUSH_CODE_ADVERT';
case pushCodePathUpdated:
return 'PUSH_CODE_PATH_UPDATED';
case pushCodeSendConfirmed:
return 'PUSH_CODE_SEND_CONFIRMED';
case pushCodeMsgWaiting:
return 'PUSH_CODE_MSG_WAITING';
case pushCodeLoginSuccess:
return 'PUSH_CODE_LOGIN_SUCCESS';
case pushCodeLoginFail:
return 'PUSH_CODE_LOGIN_FAIL';
case pushCodeLogRxData:
return 'PUSH_CODE_LOG_RX_DATA';
case pushCodeNewAdvert:
return 'PUSH_CODE_NEW_ADVERT';
default:
return 'CODE_$code';
}
}
String _frameDetail(int code, Uint8List frame) {
switch (code) {
case respCodeSent:
if (frame.length >= 10) {
final timeoutMs = readUint32LE(frame, 6);
return ' • timeout=${timeoutMs}ms';
}
return '';
case pushCodeSendConfirmed:
if (frame.length >= 9) {
final tripMs = readUint32LE(frame, 5);
return ' • trip=${tripMs}ms';
}
return '';
case pushCodeLoginSuccess:
return ' • login ok';
case pushCodeLoginFail:
return ' • login fail';
case respCodeBattAndStorage:
if (frame.length >= 3) {
final mv = readUint16LE(frame, 1);
return '${mv}mV';
}
return '';
default:
return '';
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:shared_preferences/shared_preferences.dart';
class MapMarkerService {
static const String _removedKey = 'map_removed_marker_ids';
Future<Set<String>> loadRemovedIds() async {
final prefs = await SharedPreferences.getInstance();
final items = prefs.getStringList(_removedKey) ?? const [];
return items.toSet();
}
Future<void> saveRemovedIds(Set<String> ids) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_removedKey, ids.toList());
}
}
+348
View File
@@ -0,0 +1,348 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/path_selection.dart';
import 'storage_service.dart';
import 'app_settings_service.dart';
class MessageRetryService extends ChangeNotifier {
static const int maxRetries = 5;
final StorageService _storage;
final Map<String, Timer> _timeoutTimers = {};
final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {};
Function(Contact, String, bool, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback;
Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback;
Function(int, int)? _calculateTimeoutCallback;
AppSettingsService? _appSettingsService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
MessageRetryService(this._storage);
void initialize({
required Function(Contact, String, bool, int, int) sendMessageCallback,
required Function(String, Message) addMessageCallback,
required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
AppSettingsService? appSettingsService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
}) {
_sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback;
_updateMessageCallback = updateMessageCallback;
_clearContactPathCallback = clearContactPathCallback;
_calculateTimeoutCallback = calculateTimeoutCallback;
_appSettingsService = appSettingsService;
_recordPathResultCallback = recordPathResultCallback;
}
Future<void> sendMessageWithRetry({
required Contact contact,
required String text,
bool forceFlood = false,
PathSelection? pathSelection,
Uint8List? pathBytes,
int? pathLength,
}) async {
final messageId = const Uuid().v4();
final effectiveForceFlood = forceFlood || (pathSelection?.useFlood ?? false);
final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, effectiveForceFlood, pathSelection);
final messagePathLength =
pathLength ?? _resolveMessagePathLength(contact, effectiveForceFlood, pathSelection);
final message = Message(
senderKey: contact.publicKey,
text: text,
timestamp: DateTime.now(),
isOutgoing: true,
status: MessageStatus.pending,
messageId: messageId,
retryCount: 0,
forceFlood: effectiveForceFlood,
pathLength: messagePathLength,
pathBytes: messagePathBytes,
);
_pendingMessages[messageId] = message;
_pendingContacts[messageId] = contact;
if (pathSelection != null) {
_pendingPathSelections[messageId] = pathSelection;
}
if (_addMessageCallback != null) {
_addMessageCallback!(contact.publicKeyHex, message);
}
await _attemptSend(messageId);
}
Future<void> _attemptSend(String messageId) async {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
if (message == null || contact == null) return;
Contact sendContact = contact;
final attempt = message.retryCount.clamp(0, 3);
if (message.forceFlood && contact.pathLength >= 0) {
sendContact = Contact(
publicKey: contact.publicKey,
name: contact.name,
type: contact.type,
pathLength: -1,
path: contact.path,
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: contact.lastSeen,
);
}
if (_sendMessageCallback != null) {
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
_sendMessageCallback!(
sendContact,
message.text,
message.forceFlood,
attempt,
timestampSeconds,
);
}
}
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.status == MessageStatus.pending) {
final contact = _pendingContacts[entry.key];
final selection = _pendingPathSelections[entry.key];
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) {
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = message.forceFlood ? -1 : contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops');
}
final updatedMessage = message.copyWith(
status: MessageStatus.sent,
expectedAckHash: ackHash,
estimatedTimeoutMs: actualTimeout,
sentAt: DateTime.now(),
);
_pendingMessages[entry.key] = updatedMessage;
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
_startTimeoutTimer(entry.key, actualTimeout);
return;
}
}
}
void _startTimeoutTimer(String messageId, int timeoutMs) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
_handleTimeout(messageId);
});
}
void _handleTimeout(String messageId) {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
final selection = _pendingPathSelections[messageId];
if (message == null || contact == null) return;
if (message.retryCount < maxRetries - 1) {
final backoffMs = 1000 * (1 << message.retryCount);
final updatedMessage = message.copyWith(
retryCount: message.retryCount + 1,
status: MessageStatus.pending,
);
_pendingMessages[messageId] = updatedMessage;
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
Timer(Duration(milliseconds: backoffMs), () {
_attemptSend(messageId);
});
} else {
// Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
// Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
_clearContactPathCallback != null) {
_clearContactPathCallback!(contact);
}
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null);
if (_updateMessageCallback != null) {
_updateMessageCallback!(failedMessage);
}
notifyListeners();
}
}
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
String? matchedMessageId;
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash)) {
matchedMessageId = entry.key;
break;
}
}
if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId]!;
final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId];
_timeoutTimers[matchedMessageId]?.cancel();
_timeoutTimers.remove(matchedMessageId);
final deliveredMessage = message.copyWith(
status: MessageStatus.delivered,
deliveredAt: DateTime.now(),
tripTimeMs: tripTimeMs,
);
_pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId);
if (_updateMessageCallback != null) {
_updateMessageCallback!(deliveredMessage);
}
if (contact != null) {
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs);
}
notifyListeners();
}
}
Uint8List _resolveMessagePathBytes(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return Uint8List(0);
}
if (selection != null && selection.pathBytes.isNotEmpty) {
return Uint8List.fromList(selection.pathBytes);
}
return contact.path;
}
int? _resolveMessagePathLength(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return -1;
}
if (selection != null && selection.pathBytes.isNotEmpty) {
return selection.hopCount;
}
return contact.pathLength;
}
String? getContactKeyForAckHash(Uint8List ackHash) {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash)) {
final contact = _pendingContacts[entry.key];
return contact?.publicKeyHex;
}
}
return null;
}
int calculateDefaultTimeout(Contact contact) {
if (contact.pathLength < 0) {
return 15000;
} else {
return 3000 + (3000 * contact.pathLength);
}
}
void _recordPathResultFromMessage(
String contactKey,
Message message,
PathSelection? selection,
bool success,
int? tripTimeMs,
) {
if (_recordPathResultCallback == null) return;
final recordSelection = selection ?? _selectionFromMessage(message);
if (recordSelection == null) return;
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs);
}
PathSelection? _selectionFromMessage(Message message) {
if (message.forceFlood || (message.pathLength != null && message.pathLength! < 0)) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
if (message.pathBytes.isEmpty && message.pathLength == null) {
return null;
}
return PathSelection(
pathBytes: message.pathBytes,
hopCount: message.pathLength ?? message.pathBytes.length,
useFlood: false,
);
}
@override
void dispose() {
for (var timer in _timeoutTimers.values) {
timer.cancel();
}
_timeoutTimers.clear();
_pendingMessages.clear();
_pendingContacts.clear();
_pendingPathSelections.clear();
super.dispose();
}
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
Future<void> initialize() async {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
try {
await _notifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTapped,
);
_isInitialized = true;
} catch (e) {
debugPrint('Error initializing notifications: $e');
}
}
Future<bool> requestPermissions() async {
if (!_isInitialized) {
await initialize();
}
// Request Android 13+ notification permission
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission();
return granted ?? false;
}
// iOS permissions are requested during initialization
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
);
return granted ?? false;
}
return true;
}
Future<void> showMessageNotification({
required String contactName,
required String message,
String? contactId,
}) async {
if (!_isInitialized) {
await initialize();
}
const androidDetails = AndroidNotificationDetails(
'messages',
'Messages',
channelDescription: 'New message notifications',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
contactId?.hashCode ?? 0,
'New message from $contactName',
message.length > 100 ? '${message.substring(0, 100)}...' : message,
notificationDetails,
payload: 'message:$contactId',
);
}
Future<void> showAdvertNotification({
required String contactName,
required String contactType,
String? contactId,
}) async {
if (!_isInitialized) {
await initialize();
}
const androidDetails = AndroidNotificationDetails(
'adverts',
'Advertisements',
channelDescription: 'New node advertisement notifications',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
'New $contactType discovered',
contactName,
notificationDetails,
payload: 'advert:$contactId',
);
}
void _onNotificationTapped(NotificationResponse response) {
final payload = response.payload;
if (payload != null) {
debugPrint('Notification tapped: $payload');
// Handle navigation based on payload
// This can be extended to navigate to specific screens
}
}
Future<void> cancelAll() async {
await _notifications.cancelAll();
}
Future<void> cancel(int id) async {
await _notifications.cancel(id);
}
}
+324
View File
@@ -0,0 +1,324 @@
import 'package:flutter/foundation.dart';
import '../models/contact.dart';
import '../models/path_history.dart';
import '../models/path_selection.dart';
import 'storage_service.dart';
class PathHistoryService extends ChangeNotifier {
final StorageService _storage;
final Map<String, ContactPathHistory> _cache = {};
final Map<String, int> _autoRotationIndex = {};
final Map<String, _FloodStats> _floodStats = {};
static const int _maxHistoryEntries = 100;
static const int _autoRotationTopCount = 3;
PathHistoryService(this._storage);
Future<void> initialize() async {
// Load cached path histories on startup if needed
}
void handlePathUpdated(Contact contact) {
if (contact.pathLength < 0) return;
_addPathRecord(
contactPubKeyHex: contact.publicKeyHex,
hopCount: contact.pathLength,
tripTimeMs: 0,
wasFloodDiscovery: true,
pathBytes: contact.path,
successCount: 0,
failureCount: 0,
);
}
void recordPathAttempt(String contactPubKeyHex, PathSelection selection) {
if (selection.useFlood) {
_updateFloodStats(contactPubKeyHex);
return;
}
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: selection.hopCount,
tripTimeMs: 0,
wasFloodDiscovery: false,
pathBytes: selection.pathBytes,
successCount: 0,
failureCount: 0,
);
}
void recordPathResult(
String contactPubKeyHex,
PathSelection selection, {
required bool success,
int? tripTimeMs,
}) {
if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
if (success) {
stats.successCount += 1;
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
} else {
stats.failureCount += 1;
}
stats.lastUsed = DateTime.now();
return;
}
final existing = _findPathRecord(contactPubKeyHex, selection.pathBytes);
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: selection.hopCount,
tripTimeMs: success ? (tripTimeMs ?? 0) : (existing?.tripTimeMs ?? 0),
wasFloodDiscovery: existing?.wasFloodDiscovery ?? false,
pathBytes: selection.pathBytes,
successCount: successCount,
failureCount: failureCount,
);
}
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(contactPubKeyHex)
.take(_autoRotationTopCount)
.toList();
if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
final selections = ranked
.map((path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
))
.toList()
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true));
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selection = selections[currentIndex % selections.length];
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
return selection;
}
void _addPathRecord({
required String contactPubKeyHex,
required int hopCount,
required int tripTimeMs,
required bool wasFloodDiscovery,
required List<int> pathBytes,
required int successCount,
required int failureCount,
}) {
var history = _cache[contactPubKeyHex];
if (history == null) {
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
} else {
_cache[contactPubKeyHex] = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: [],
);
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
}
});
return;
}
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
}
void _addPathRecordInternal(
String contactPubKeyHex,
int hopCount,
int tripTimeMs,
bool wasFloodDiscovery,
List<int> pathBytes,
int successCount,
int failureCount,
) {
var history = _cache[contactPubKeyHex];
if (history == null) return;
final existing = _findPathRecord(contactPubKeyHex, pathBytes);
if (existing != null) {
successCount = successCount == 0 ? existing.successCount : successCount;
failureCount = failureCount == 0 ? existing.failureCount : failureCount;
if (tripTimeMs == 0) {
tripTimeMs = existing.tripTimeMs;
}
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
}
final newRecord = PathRecord(
hopCount: hopCount,
tripTimeMs: tripTimeMs,
timestamp: DateTime.now(),
wasFloodDiscovery: wasFloodDiscovery,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
);
final updatedPaths = List<PathRecord>.from(history.recentPaths);
updatedPaths.removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes));
if (existing == null && updatedPaths.length >= _maxHistoryEntries) {
return;
}
updatedPaths.insert(0, newRecord);
final updatedHistory = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: updatedPaths,
);
_cache[contactPubKeyHex] = updatedHistory;
_storage.savePathHistory(contactPubKeyHex, updatedHistory);
notifyListeners();
}
List<PathRecord> getRecentPaths(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
if (history != null) {
return history.recentPaths;
}
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
notifyListeners();
}
});
return [];
}
Future<ContactPathHistory?> _loadHistoryFromStorage(
String contactPubKeyHex) async {
return await _storage.loadPathHistory(contactPubKeyHex);
}
PathRecord? getFastestPath(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
return history?.fastest;
}
PathRecord? getMostRecentPath(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
return history?.mostRecent;
}
Future<void> clearPathHistory(String contactPubKeyHex) async {
_cache.remove(contactPubKeyHex);
_autoRotationIndex.remove(contactPubKeyHex);
_floodStats.remove(contactPubKeyHex);
await _storage.clearPathHistory(contactPubKeyHex);
notifyListeners();
}
Future<void> removePathRecord(
String contactPubKeyHex,
List<int> pathBytes,
) async {
final history = _cache[contactPubKeyHex];
if (history == null) return;
final updatedPaths = List<PathRecord>.from(history.recentPaths)
..removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes));
_cache[contactPubKeyHex] = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: updatedPaths,
);
await _storage.savePathHistory(contactPubKeyHex, _cache[contactPubKeyHex]!);
notifyListeners();
}
PathRecord? _findPathRecord(String contactPubKeyHex, List<int> pathBytes) {
final history = _cache[contactPubKeyHex];
if (history == null) return null;
for (final record in history.recentPaths) {
if (_pathsEqual(record.pathBytes, pathBytes)) {
return record;
}
}
return null;
}
List<PathRecord> _getRankedPaths(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
if (history == null) return [];
final ranked = List<PathRecord>.from(history.recentPaths)
..removeWhere((p) => p.pathBytes.isEmpty);
ranked.sort((a, b) {
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2);
if (aRate != bRate) return bRate.compareTo(aRate);
if (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount);
}
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
return b.timestamp.compareTo(a.timestamp);
});
return ranked;
}
bool _pathsEqual(List<int> a, List<int> b) {
return listEquals(a, b);
}
void _updateFloodStats(String contactPubKeyHex) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
stats.lastUsed = DateTime.now();
}
}
class _FloodStats {
int successCount = 0;
int failureCount = 0;
int lastTripTimeMs = 0;
DateTime? lastUsed;
}
+133
View File
@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:typed_data';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
class RepeaterCommandService {
final MeshCoreConnector _connector;
final Map<String, Completer<String>> _pendingCommands = {};
final Map<String, Timer> _commandTimeouts = {};
final Map<String, String> _commandPrefixes = {};
final Map<String, String> _pendingByPrefix = {};
int _prefixCounter = 0;
static const int timeoutSeconds = 10; // Flood mode timeout
static const int maxRetries = 5;
RepeaterCommandService(this._connector);
/// Send a CLI command to a repeater with automatic retries
/// Returns a future that completes when a response is received or after max retries
Future<String> sendCommand(
Contact repeater,
String command, {
Function(String)? onResponse,
Function(int)? onAttempt,
}) async {
final repeaterKey = repeater.publicKeyHex;
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
if (hasPending) {
throw Exception('Another command is still awaiting a response.');
}
// Create completer for this command
final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}';
final completer = Completer<String>();
_pendingCommands[commandId] = completer;
onAttempt?.call(0);
// Send frame once (no retries)
try {
final prefix = _nextPrefixToken();
_commandPrefixes[commandId] = prefix;
_pendingByPrefix[prefix] = commandId;
final framedCommand = '$prefix$command';
final frame = buildSendCliCommandFrame(repeater.publicKey, framedCommand, attempt: 0);
await _connector.sendFrame(frame);
} catch (e) {
_cleanup(commandId);
throw Exception('Failed to send command: $e');
}
// Set timeout for this attempt
_commandTimeouts[commandId]?.cancel();
_commandTimeouts[commandId] = Timer(
Duration(seconds: timeoutSeconds),
() {
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.completeError('Command timeout after $timeoutSeconds seconds');
_cleanup(commandId);
}
},
);
// Wait for response or timeout
try {
final response = await completer.future;
return response;
} finally {
_cleanup(commandId);
}
}
/// Call this when a text message response is received from a repeater
void handleResponse(Contact repeater, String responseText) {
// Find pending command for this repeater and complete it
final repeaterKey = repeater.publicKeyHex;
String? commandId;
String responsePayload = responseText;
if (responseText.length >= 3 && responseText[2] == '|') {
final prefix = responseText.substring(0, 3);
commandId = _pendingByPrefix[prefix];
responsePayload = responseText.substring(3).trimLeft();
}
commandId ??= _pendingCommands.keys.firstWhere(
(id) => id.startsWith(repeaterKey),
orElse: () => '',
);
if (commandId.isEmpty) return;
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.complete(responsePayload);
_cleanup(commandId);
}
}
void _cleanup(String commandId) {
_commandTimeouts[commandId]?.cancel();
_commandTimeouts.remove(commandId);
_pendingCommands.remove(commandId);
final prefix = _commandPrefixes.remove(commandId);
if (prefix != null) {
_pendingByPrefix.remove(prefix);
}
}
void dispose() {
for (final timer in _commandTimeouts.values) {
timer.cancel();
}
_commandTimeouts.clear();
_pendingCommands.clear();
_commandPrefixes.clear();
_pendingByPrefix.clear();
}
String _nextPrefixToken() {
for (var i = 0; i < 256; i++) {
final value = _prefixCounter++ & 0xFF;
final token = '${value.toRadixString(16).padLeft(2, '0').toUpperCase()}|';
if (!_pendingByPrefix.containsKey(token)) {
return token;
}
}
return '00|';
}
}
+120
View File
@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/path_history.dart';
class StorageService {
static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords';
Future<void> savePathHistory(
String contactPubKeyHex, ContactPathHistory history) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = jsonEncode(history.toJson());
await prefs.setString(key, jsonStr);
}
Future<ContactPathHistory?> loadPathHistory(String contactPubKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = prefs.getString(key);
if (jsonStr == null) return null;
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return ContactPathHistory.fromJson(contactPubKeyHex, json);
} catch (e) {
return null;
}
}
Future<void> clearPathHistory(String contactPubKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_pathHistoryPrefix$contactPubKeyHex';
await prefs.remove(key);
}
Future<void> clearAllPathHistories() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
final pathHistoryKeys =
keys.where((key) => key.startsWith(_pathHistoryPrefix));
for (final key in pathHistoryKeys) {
await prefs.remove(key);
}
}
Future<Map<String, String>> loadPendingMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMessagesKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as String));
} catch (e) {
return {};
}
}
Future<void> savePendingMessages(Map<String, String> pending) async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = jsonEncode(pending);
await prefs.setString(_pendingMessagesKey, jsonStr);
}
Future<void> clearPendingMessages() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingMessagesKey);
}
/// Save a repeater password by public key hex
Future<void> saveRepeaterPassword(
String repeaterPubKeyHex, String password) async {
final prefs = await SharedPreferences.getInstance();
final passwords = await loadRepeaterPasswords();
passwords[repeaterPubKeyHex] = password;
final jsonStr = jsonEncode(passwords);
await prefs.setString(_repeaterPasswordsKey, jsonStr);
}
/// Load all saved repeater passwords (map of pubKeyHex -> password)
Future<Map<String, String>> loadRepeaterPasswords() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_repeaterPasswordsKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as String));
} catch (e) {
return {};
}
}
/// Get a specific repeater's saved password
Future<String?> getRepeaterPassword(String repeaterPubKeyHex) async {
final passwords = await loadRepeaterPasswords();
return passwords[repeaterPubKeyHex];
}
/// Remove a saved repeater password
Future<void> removeRepeaterPassword(String repeaterPubKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final passwords = await loadRepeaterPasswords();
passwords.remove(repeaterPubKeyHex);
final jsonStr = jsonEncode(passwords);
await prefs.setString(_repeaterPasswordsKey, jsonStr);
}
/// Clear all saved repeater passwords
Future<void> clearAllRepeaterPasswords() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_repeaterPasswordsKey);
}
}
+119
View File
@@ -0,0 +1,119 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/channel_message.dart';
import '../helpers/smaz.dart';
class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_';
/// Save messages for a specific channel
Future<void> saveChannelMessages(int channelIndex, List<ChannelMessage> messages) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_keyPrefix$channelIndex';
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(key, jsonString);
}
/// Load messages for a specific channel
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_keyPrefix$channelIndex';
final jsonString = prefs.getString(key);
if (jsonString == null) return [];
try {
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList();
} catch (e) {
// If parsing fails, return empty list
return [];
}
}
/// Clear messages for a specific channel
Future<void> clearChannelMessages(int channelIndex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_keyPrefix$channelIndex';
await prefs.remove(key);
}
/// Clear all channel messages
Future<void> clearAllChannelMessages() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix));
for (var key in keys) {
await prefs.remove(key);
}
}
/// Convert ChannelMessage to JSON map
Map<String, dynamic> _messageToJson(ChannelMessage msg) {
return {
'senderKey': msg.senderKey != null ? base64Encode(msg.senderKey!) : null,
'senderName': msg.senderName,
'text': msg.text,
'timestamp': msg.timestamp.millisecondsSinceEpoch,
'isOutgoing': msg.isOutgoing,
'status': msg.status.index,
'channelIndex': msg.channelIndex,
'repeatCount': msg.repeatCount,
'pathLength': msg.pathLength,
'pathBytes': base64Encode(msg.pathBytes),
'repeats': msg.repeats.map(_repeatToJson).toList(),
};
}
/// Convert JSON map to ChannelMessage
ChannelMessage _messageFromJson(Map<String, dynamic> json) {
final rawText = json['text'] as String;
final decodedText = Smaz.tryDecodePrefixed(rawText) ?? rawText;
return ChannelMessage(
senderKey: json['senderKey'] != null
? Uint8List.fromList(base64Decode(json['senderKey']))
: null,
senderName: json['senderName'] as String,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int),
isOutgoing: json['isOutgoing'] as bool,
status: ChannelMessageStatus.values[json['status'] as int],
repeatCount: (json['repeatCount'] as int?) ?? 0,
pathLength: json['pathLength'] as int?,
pathBytes: json['pathBytes'] != null
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0),
repeats: (json['repeats'] as List<dynamic>?)
?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>))
.toList() ??
const [],
channelIndex: json['channelIndex'] as int?,
);
}
Map<String, dynamic> _repeatToJson(Repeat repeat) {
return {
'repeaterKey': repeat.repeaterKey != null ? base64Encode(repeat.repeaterKey!) : null,
'repeaterName': repeat.repeaterName,
'tripTimeMs': repeat.tripTimeMs,
'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [],
};
}
Repeat _repeatFromJson(Map<String, dynamic> json) {
return Repeat(
repeaterKey: json['repeaterKey'] != null
? Uint8List.fromList(base64Decode(json['repeaterKey']))
: null,
repeaterName: json['repeaterName'] as String? ?? 'Unknown',
tripTimeMs: json['tripTimeMs'] as int? ?? 0,
path: (json['path'] as List<dynamic>?)
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
.toList(),
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class ChannelOrderStore {
static const String _key = 'channel_order';
Future<void> saveChannelOrder(List<int> order) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, jsonEncode(order));
}
Future<List<int>> loadChannelOrder() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded.map((value) => value is int ? value : int.tryParse('$value')).whereType<int>().toList();
}
} catch (_) {
// fall through to legacy parse
}
return raw
.split(',')
.map((value) => int.tryParse(value))
.whereType<int>()
.toList();
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:shared_preferences/shared_preferences.dart';
class ChannelSettingsStore {
static const String _smazKeyPrefix = 'channel_smaz_';
Future<bool> loadSmazEnabled(int channelIndex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_smazKeyPrefix$channelIndex';
return prefs.getBool(key) ?? false;
}
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_smazKeyPrefix$channelIndex';
await prefs.setBool(key, enabled);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact_group.dart';
class ContactGroupStore {
static const String _key = 'contact_groups';
Future<List<ContactGroup>> loadGroups() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded
.whereType<Map<String, dynamic>>()
.map(ContactGroup.fromJson)
.toList();
}
} catch (_) {
// Return empty list on parse errors.
}
return [];
}
Future<void> saveGroups(List<ContactGroup> groups) async {
final prefs = await SharedPreferences.getInstance();
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
await prefs.setString(_key, encoded);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
class ContactStore {
static const String _key = 'contacts';
Future<List<Contact>> loadContacts() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_key);
if (jsonStr == null) return [];
try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
return jsonList.map((entry) => _fromJson(entry as Map<String, dynamic>)).toList();
} catch (_) {
return [];
}
}
Future<void> saveContacts(List<Contact> contacts) async {
final prefs = await SharedPreferences.getInstance();
final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Contact contact) {
return {
'publicKey': base64Encode(contact.publicKey),
'name': contact.name,
'type': contact.type,
'pathLength': contact.pathLength,
'path': base64Encode(contact.path),
'latitude': contact.latitude,
'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
};
}
Contact _fromJson(Map<String, dynamic> json) {
return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown',
type: json['type'] as int? ?? 0,
pathLength: json['pathLength'] as int? ?? -1,
path: json['path'] != null
? Uint8List.fromList(base64Decode(json['path'] as String))
: Uint8List(0),
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(json['lastSeen'] as int? ?? 0),
);
}
}
+85
View File
@@ -0,0 +1,85 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart';
class MessageStore {
static const String _keyPrefix = 'messages_';
Future<void> saveMessages(String contactKeyHex, List<Message> messages) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_keyPrefix$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList();
await prefs.setString(key, jsonEncode(jsonList));
}
Future<List<Message>> loadMessages(String contactKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_keyPrefix$contactKeyHex';
final jsonString = prefs.getString(key);
if (jsonString == null) return [];
try {
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList();
} catch (e) {
return [];
}
}
Future<void> clearMessages(String contactKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_keyPrefix$contactKeyHex';
await prefs.remove(key);
}
Map<String, dynamic> _messageToJson(Message msg) {
return {
'senderKey': base64Encode(msg.senderKey),
'text': msg.text,
'timestamp': msg.timestamp.millisecondsSinceEpoch,
'isOutgoing': msg.isOutgoing,
'isCli': msg.isCli,
'status': msg.status.index,
'messageId': msg.messageId,
'retryCount': msg.retryCount,
'estimatedTimeoutMs': msg.estimatedTimeoutMs,
'expectedAckHash': msg.expectedAckHash != null ? base64Encode(msg.expectedAckHash!) : null,
'sentAt': msg.sentAt?.millisecondsSinceEpoch,
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
'tripTimeMs': msg.tripTimeMs,
'forceFlood': msg.forceFlood,
'pathLength': msg.pathLength,
'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null,
};
}
Message _messageFromJson(Map<String, dynamic> json) {
return Message(
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
text: json['text'] as String,
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int),
isOutgoing: json['isOutgoing'] as bool,
isCli: json['isCli'] as bool? ?? false,
status: MessageStatus.values[json['status'] as int],
messageId: json['messageId'] as String?,
retryCount: json['retryCount'] as int? ?? 0,
estimatedTimeoutMs: json['estimatedTimeoutMs'] as int?,
expectedAckHash: json['expectedAckHash'] != null
? Uint8List.fromList(base64Decode(json['expectedAckHash'] as String))
: null,
sentAt: json['sentAt'] != null
? DateTime.fromMillisecondsSinceEpoch(json['sentAt'] as int)
: null,
deliveredAt: json['deliveredAt'] != null
? DateTime.fromMillisecondsSinceEpoch(json['deliveredAt'] as int)
: null,
tripTimeMs: json['tripTimeMs'] as int?,
forceFlood: json['forceFlood'] as bool? ?? false,
pathLength: json['pathLength'] as int?,
pathBytes: json['pathBytes'] != null
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:characters/characters.dart';
String? firstEmoji(String name) {
if (name.isEmpty) return null;
for (final cluster in name.characters) {
if (_containsEmoji(cluster)) {
return cluster;
}
}
return null;
}
bool _containsEmoji(String cluster) {
for (final rune in cluster.runes) {
if (_isEmojiRune(rune)) return true;
}
return false;
}
bool _isEmojiRune(int rune) {
if (rune == 0x00A9 || rune == 0x00AE) return true;
if (rune == 0x203C || rune == 0x2049) return true;
if (rune == 0x2122 || rune == 0x2139) return true;
if (rune >= 0x2194 && rune <= 0x21AA) return true;
if (rune >= 0x2300 && rune <= 0x23FF) return true;
if (rune >= 0x2460 && rune <= 0x24FF) return true;
if (rune >= 0x25A0 && rune <= 0x27BF) return true;
if (rune >= 0x2900 && rune <= 0x297F) return true;
if (rune >= 0x2B00 && rune <= 0x2BFF) return true;
if (rune >= 0x1F000 && rune <= 0x1FAFF) return true;
if (rune >= 0x1FC00 && rune <= 0x1FFFD) return true;
return false;
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../connector/meshcore_protocol.dart';
/// Debug widget to show the hex dump of a frame
class DebugFrameViewer {
static void showFrameDebug(BuildContext context, Uint8List frame, String title) {
final hexString = frame
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' ');
final details = StringBuffer();
details.writeln('Frame Length: ${frame.length} bytes\n');
details.writeln('Command: 0x${frame[0].toRadixString(16).padLeft(2, '0')}');
if (frame[0] == cmdSendTxtMsg && frame.length > 37) {
details.writeln('\nText Message Frame:');
details.writeln('- Destination PubKey: ${pubKeyToHex(frame.sublist(1, 33))}');
details.writeln('- Timestamp: ${readUint32LE(frame, 33)}');
details.writeln('- Flags: 0x${frame[37].toRadixString(16).padLeft(2, '0')}');
final txtType = (frame[37] >> 2) & 0x03;
details.writeln('- Text Type: $txtType ${txtType == txtTypeCliData ? "(CLI)" : "(Plain)"}');
if (frame.length > 38) {
final textBytes = frame.sublist(38);
final nullIdx = textBytes.indexOf(0);
final text = String.fromCharCodes(
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes
);
details.writeln('- Text: "$text"');
}
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
details.toString(),
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
const Divider(),
const Text(
'Hex Dump:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
hexString,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
}
+68
View File
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
/// A reusable tile widget for displaying a MeshCore device in a list
class DeviceTile extends StatelessWidget {
final ScanResult scanResult;
final VoidCallback onTap;
const DeviceTile({
super.key,
required this.scanResult,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final device = scanResult.device;
final rssi = scanResult.rssi;
final name = device.platformName.isNotEmpty
? device.platformName
: scanResult.advertisementData.advName;
return ListTile(
leading: _buildSignalIcon(rssi),
title: Text(
name.isNotEmpty ? name : 'Unknown Device',
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(device.remoteId.toString()),
trailing: ElevatedButton(
onPressed: onTap,
child: const Text('Connect'),
),
onTap: onTap,
);
}
Widget _buildSignalIcon(int rssi) {
IconData icon;
Color color;
if (rssi >= -60) {
icon = Icons.signal_cellular_4_bar;
color = Colors.green;
} else if (rssi >= -70) {
icon = Icons.signal_cellular_alt;
color = Colors.lightGreen;
} else if (rssi >= -80) {
icon = Icons.signal_cellular_alt_2_bar;
color = Colors.orange;
} else {
icon = Icons.signal_cellular_alt_1_bar;
color = Colors.red;
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color),
Text(
'$rssi dBm',
style: TextStyle(fontSize: 10, color: color),
),
],
);
}
}
+185
View File
@@ -0,0 +1,185 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class GifMessage extends StatefulWidget {
final String url;
final Color backgroundColor;
final Color fallbackTextColor;
final double width;
final double height;
const GifMessage({
super.key,
required this.url,
required this.backgroundColor,
required this.fallbackTextColor,
this.width = 200,
this.height = 140,
});
@override
State<GifMessage> createState() => _GifMessageState();
}
class _GifMessageState extends State<GifMessage> {
ImageStream? _imageStream;
ImageStreamListener? _listener;
ui.Image? _image;
Object? _error;
bool _isLoading = true;
bool _isPaused = false;
@override
void initState() {
super.initState();
_resolveImage();
}
@override
void didUpdateWidget(covariant GifMessage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.url != widget.url) {
_unsubscribe();
_image = null;
_error = null;
_isLoading = true;
_isPaused = false;
_resolveImage();
}
}
@override
void dispose() {
_unsubscribe();
super.dispose();
}
void _resolveImage() {
setState(() {
_isLoading = true;
_error = null;
});
final provider = NetworkImage(widget.url);
final stream = provider.resolve(ImageConfiguration.empty);
_imageStream = stream;
_listener = ImageStreamListener(
(imageInfo, _) {
if (_isPaused) {
return;
}
setState(() {
_image = imageInfo.image;
_isLoading = false;
});
},
onError: (error, _) {
setState(() {
_error = error;
_isLoading = false;
});
},
);
stream.addListener(_listener!);
}
void _retryLoad() {
_unsubscribe();
_image = null;
_isPaused = false;
_resolveImage();
}
void _unsubscribe() {
if (_imageStream != null && _listener != null) {
_imageStream!.removeListener(_listener!);
}
}
void _togglePause() {
if (_error != null) {
_retryLoad();
return;
}
if (_image == null) {
if (!_isLoading) {
_retryLoad();
}
return;
}
setState(() {
_isPaused = !_isPaused;
});
if (_listener == null || _imageStream == null) {
return;
}
if (_isPaused) {
_imageStream!.removeListener(_listener!);
} else {
_imageStream!.addListener(_listener!);
}
}
@override
Widget build(BuildContext context) {
Widget content;
if (_error != null) {
content = Center(
child: Text(
"Can't load GIF\nTap to retry",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: widget.fallbackTextColor),
),
);
} else if (_isLoading && _image == null) {
content = const Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
} else if (_image == null) {
content = Center(
child: Text(
'Tap to load GIF',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: widget.fallbackTextColor),
),
);
} else {
content = RawImage(
image: _image,
fit: BoxFit.cover,
width: widget.width,
height: widget.height,
);
}
return GestureDetector(
onTap: _togglePause,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
color: widget.backgroundColor,
width: widget.width,
height: widget.height,
child: Stack(
fit: StackFit.expand,
children: [
content,
if (_isPaused && _image != null)
Container(
color: Colors.black.withValues(alpha: 0.2),
child: const Center(
child: Icon(Icons.pause, color: Colors.white70, size: 28),
),
),
],
),
),
),
);
}
}
+283
View File
@@ -0,0 +1,283 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class GifPicker extends StatefulWidget {
final Function(String gifId) onGifSelected;
const GifPicker({
super.key,
required this.onGifSelected,
});
@override
State<GifPicker> createState() => _GifPickerState();
}
class _GifPickerState extends State<GifPicker> {
final TextEditingController _searchController = TextEditingController();
List<Map<String, dynamic>> _gifs = [];
bool _isLoading = false;
String? _error;
// Giphy API key - Using public beta key (limited usage)
// For production, replace with your own Giphy API key from developers.giphy.com
static const String _giphyApiKey = 'sXpGFDGZs0Dv1mmNFvYaGUvYwKX0PWIh';
@override
void initState() {
super.initState();
_loadTrendingGifs();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadTrendingGifs() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final response = await http.get(
Uri.parse(
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g',
),
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
setState(() {
_gifs = List<Map<String, dynamic>>.from(data['data']);
_isLoading = false;
});
} else {
setState(() {
_error = 'Failed to load GIFs';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_error = 'No internet connection';
_isLoading = false;
});
}
}
Future<void> _searchGifs(String query) async {
if (query.trim().isEmpty) {
_loadTrendingGifs();
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final response = await http.get(
Uri.parse(
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=25&rating=g',
),
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
setState(() {
_gifs = List<Map<String, dynamic>>.from(data['data']);
_isLoading = false;
});
} else {
setState(() {
_error = 'Failed to search GIFs';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_error = 'No internet connection';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height * 0.7,
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Header
Row(
children: [
const Icon(Icons.gif_box, size: 28),
const SizedBox(width: 8),
const Text(
'Choose a GIF',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
// Search bar
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search GIFs...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_loadTrendingGifs();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
textInputAction: TextInputAction.search,
onSubmitted: _searchGifs,
onChanged: (value) {
setState(() {}); // Update to show/hide clear button
},
),
const SizedBox(height: 16),
// GIF grid
Expanded(
child: _buildContent(),
),
// Powered by Giphy attribution
const SizedBox(height: 8),
Text(
'Powered by GIPHY',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
),
);
}
Widget _buildContent() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
_error!,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _loadTrendingGifs,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
if (_gifs.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No GIFs found',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
);
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.0,
),
itemCount: _gifs.length,
itemBuilder: (context, index) {
final gif = _gifs[index];
final gifId = gif['id'] as String;
final previewUrl = gif['images']?['fixed_height_small']?['url'] as String?;
return GestureDetector(
onTap: () {
widget.onGifSelected(gifId);
Navigator.pop(context);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: previewUrl != null
? Image.network(
previewUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(Icons.error_outline),
);
},
)
: const Center(
child: Icon(Icons.gif_box),
),
),
),
);
},
);
}
}
+286
View File
@@ -0,0 +1,286 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../services/storage_service.dart';
import '../services/repeater_command_service.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
class RepeaterLoginDialog extends StatefulWidget {
final Contact repeater;
final Function(String password) onLogin;
const RepeaterLoginDialog({
super.key,
required this.repeater,
required this.onLogin,
});
@override
State<RepeaterLoginDialog> createState() => _RepeaterLoginDialogState();
}
class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
final TextEditingController _passwordController = TextEditingController();
final StorageService _storage = StorageService();
bool _savePassword = false;
bool _isLoading = true;
bool _obscurePassword = true;
late MeshCoreConnector _connector;
int _currentAttempt = 0;
final int _maxAttempts = RepeaterCommandService.maxRetries;
static const int _loginTimeoutSeconds = 10;
@override
void initState() {
super.initState();
_connector = Provider.of<MeshCoreConnector>(context, listen: false);
_loadSavedPassword();
}
Future<void> _loadSavedPassword() async {
final savedPassword =
await _storage.getRepeaterPassword(widget.repeater.publicKeyHex);
if (savedPassword != null) {
setState(() {
_passwordController.text = savedPassword;
_savePassword = true;
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
}
}
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
bool _isLoggingIn = false;
Future<void> _handleLogin() async {
if (_isLoggingIn) return;
setState(() {
_isLoggingIn = true;
_currentAttempt = 0;
});
try {
final password = _passwordController.text;
bool? loginResult;
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return;
setState(() {
_currentAttempt = attempt + 1;
});
await _connector.sendFrame(
buildSendLoginFrame(widget.repeater.publicKey, password),
);
loginResult = await _awaitLoginResponse();
if (loginResult == true) {
break;
}
if (loginResult == false) {
throw Exception('Wrong password or node is unreachable');
}
}
if (loginResult != true) {
throw Exception('Wrong password or node is unreachable');
}
// If we got a response, login succeeded
if (mounted) {
// Save password if requested
if (_savePassword) {
await _storage.saveRepeaterPassword(
widget.repeater.publicKeyHex, password);
} else {
// Remove saved password if user unchecked the box
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex);
}
Navigator.pop(context, password);
Future.microtask(() => widget.onLogin(password));
}
} catch (e) {
if (mounted) {
setState(() {
_isLoggingIn = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Login failed: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<bool?> _awaitLoginResponse() async {
final completer = Completer<bool?>();
Timer? timer;
StreamSubscription<Uint8List>? subscription;
final targetPrefix = widget.repeater.publicKey.sublist(0, 6);
subscription = _connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
final code = frame[0];
if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return;
if (frame.length < 8) return;
final prefix = frame.sublist(2, 8);
if (!listEquals(prefix, targetPrefix)) return;
completer.complete(code == pushCodeLoginSuccess);
subscription?.cancel();
timer?.cancel();
});
timer = Timer(const Duration(seconds: _loginTimeoutSeconds), () {
if (!completer.isCompleted) {
completer.complete(null);
subscription?.cancel();
}
});
final result = await completer.future;
timer?.cancel();
await subscription?.cancel();
return result;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
const Icon(Icons.cell_tower, color: Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Repeater Login'),
Text(
widget.repeater.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: Colors.grey[600],
),
),
],
),
),
],
),
content: _isLoading
? const Center(
child: Padding(
padding: EdgeInsets.all(20.0),
child: CircularProgressIndicator(),
),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter the repeater password to access settings and status.',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter password',
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: _passwordController.text.isEmpty,
),
const SizedBox(height: 12),
CheckboxListTile(
value: _savePassword,
onChanged: (value) {
setState(() {
_savePassword = value ?? false;
});
},
title: const Text(
'Save password',
style: TextStyle(fontSize: 14),
),
subtitle: const Text(
'Password will be stored securely on this device',
style: TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
if (_isLoggingIn)
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
const SizedBox(width: 12),
Text('Retries $_currentAttempt/$_maxAttempts'),
],
),
),
)
else
FilledButton.icon(
onPressed: _isLoading ? null : _handleLogin,
icon: const Icon(Icons.login, size: 18),
label: const Text('Login'),
),
],
);
}
}