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