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
+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),
);
}
}