mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-01 06:30:31 +10:00
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:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user