diff --git a/.gitignore b/.gitignore index dd3d22d..8180537 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ app.*.map.json # FVM Version Cache .fvm/ -.vscode/ \ No newline at end of file +.vscode/ +android/.kotlin/ diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/data/node/sessions/create_session.dart b/lib/data/node/sessions/create_session.dart index 8c4bb1c..bed796a 100644 --- a/lib/data/node/sessions/create_session.dart +++ b/lib/data/node/sessions/create_session.dart @@ -6,8 +6,17 @@ import 'package:flutter/material.dart'; import 'package:hushnet_frontend/services/key_provider.dart'; import 'package:hushnet_frontend/services/node_service.dart'; -/// Full X3DH + initial AES-GCM encrypt for each recipient device, then POST /sessions -Future createSession(String nodeUrl, String recipientUserId) async { +/// Full X3DH + initial AES-GCM encrypt for each recipient device, then POST /sessions. +/// +/// Pass [recipientUserAddress] (e.g. "bob@node-b.hushnet.net") for federated +/// users. The local node proxies key fetching and session forwarding. +/// [recipientUserId] is ignored server-side for remote recipients; pass the +/// nil UUID as a placeholder when you only have the federated address. +Future createSession( + String nodeUrl, + String recipientUserId, { + String? recipientUserAddress, +}) async { final keyProvider = KeyProvider(); final dio = Dio(); final NodeService nodeService = NodeService(); @@ -22,8 +31,11 @@ Future createSession(String nodeUrl, String recipientUserId) async { throw Exception('Missing identity or prekey on client'); } - // 2) get recipient devices (assumes these return X25519 pubs) - final devices = await keyProvider.getUserDevicesKeys(recipientUserId); + // 2) get recipient devices — federated path uses the proxy endpoint + final devices = recipientUserAddress != null + ? await keyProvider.getRemoteUserDevicesKeys(recipientUserAddress) + : await keyProvider.getUserDevicesKeys(recipientUserId); + if (devices.isEmpty) { debugPrint('No devices for recipient'); return false; @@ -192,7 +204,7 @@ Future createSession(String nodeUrl, String recipientUserId) async { final nonce = aes.newNonce(); final plaintext = utf8.encode( 'HushNet initial session message', - ); // customize initial message as needed + ); final secretBox = await aes.encrypt( plaintext, secretKey: rootKey, @@ -211,9 +223,8 @@ Future createSession(String nodeUrl, String recipientUserId) async { sessionsInit.add({ 'recipient_device_id': device.deviceId, 'ephemeral_pubkey': ekPubB64, - 'sender_identity_pub': base64Encode(ikPub), 'ciphertext': ciphertextB64, - 'otpk_used': device.oneTimePrekeyPub, + 'otpk_used': device.oneTimePrekeyPub ?? '', 'sender_prekey_pub': base64Encode(ikPub), }); } // end for devices @@ -245,6 +256,8 @@ Future createSession(String nodeUrl, String recipientUserId) async { final payload = { 'recipient_user_id': recipientUserId, + if (recipientUserAddress != null) + 'recipient_user_address': recipientUserAddress, 'sessions_init': sessionsInit, }; @@ -254,7 +267,8 @@ Future createSession(String nodeUrl, String recipientUserId) async { options: Options(headers: headers), ); - if (res.statusCode == 200 || res.statusCode == 201) { + // 202 means the session was forwarded to the remote node — treat as success + if (res.statusCode == 200 || res.statusCode == 201 || res.statusCode == 202) { return true; } else { debugPrint('CreateSessionFull failed http: ${res.statusCode} ${res.data}'); diff --git a/lib/main.dart b/lib/main.dart index 1c5b096..b514121 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -42,7 +42,6 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - @override Widget build(BuildContext context) { return const OnboardingScreen(); diff --git a/lib/models/chat_view.dart b/lib/models/chat_view.dart index e068681..b6bc8fd 100644 --- a/lib/models/chat_view.dart +++ b/lib/models/chat_view.dart @@ -10,6 +10,8 @@ class ChatView { final String? lastMessagePreview; final DateTime updatedAt; final String displayName; + // Populated by server for federated chats (partner_federated_address). + final String? federatedAddress; ChatView({ required this.id, @@ -21,8 +23,11 @@ class ChatView { this.lastMessagePreview, required this.updatedAt, this.displayName = '', + this.federatedAddress, }); + bool get isRemote => federatedAddress != null; + factory ChatView.fromJson(Map json) { return ChatView( id: json['id'] ?? '', @@ -34,6 +39,22 @@ class ChatView { lastMessagePreview: json['last_message_preview'], updatedAt: DateTime.parse(json['updated_at']), displayName: json['name'] ?? json['partner_username'] ?? '', + federatedAddress: json['partner_federated_address'], + ); + } + + ChatView copyWith({String? federatedAddress}) { + return ChatView( + id: id, + chatType: chatType, + partnerUserId: partnerUserId, + partnerUsername: partnerUsername, + name: name, + lastMessageId: lastMessageId, + lastMessagePreview: lastMessagePreview, + updatedAt: updatedAt, + displayName: displayName, + federatedAddress: federatedAddress ?? this.federatedAddress, ); } @@ -47,15 +68,14 @@ class ChatView { 'last_message_id': lastMessageId, 'last_message_preview': lastMessagePreview, 'updated_at': updatedAt.toIso8601String(), + if (federatedAddress != null) 'partner_federated_address': federatedAddress, }; } - /// Optional helper: format the last update nicely for UI String get formattedDate { return DateFormat('dd/MM HH:mm').format(updatedAt); } - /// Optional helper: title shown in chat list String get displayTitle { if (chatType == 'group') return name ?? 'Group chat'; return partnerUsername ?? 'Unknown'; diff --git a/lib/models/pending_sessions.dart b/lib/models/pending_sessions.dart index 991547a..43ad741 100644 --- a/lib/models/pending_sessions.dart +++ b/lib/models/pending_sessions.dart @@ -7,9 +7,10 @@ class PendingSession { final String ephemeralPubkey; // base64 final String ciphertext; // base64 final String? senderPrekeyPub; // base64 (nécessaire pour DH1) + final String? otpkUsed; // base64 public key of the OPK Alice used final String createdAt; User? senderUser; - + PendingSession({ required this.id, required this.senderDeviceId, @@ -19,6 +20,7 @@ class PendingSession { required this.createdAt, this.senderUser, this.senderPrekeyPub, + this.otpkUsed, }); factory PendingSession.fromJson(Map json) { @@ -30,6 +32,9 @@ class PendingSession { ciphertext: json['ciphertext'], createdAt: json['created_at'] ?? '', senderPrekeyPub: json['sender_prekey_pub'], + otpkUsed: json['otpk_used'] is String && (json['otpk_used'] as String).isNotEmpty + ? json['otpk_used'] + : null, ); } } diff --git a/lib/models/user_device.dart b/lib/models/user_device.dart index 472eccc..ee72b87 100644 --- a/lib/models/user_device.dart +++ b/lib/models/user_device.dart @@ -22,20 +22,45 @@ class UserDevice { deviceId: json['id'] ?? json['device_id'], identityPubkey: json['identity_pubkey'], prekeyPubkey: json['prekey_pubkey'] ?? json['prekey']?['key'], - signedPrekeyPub: json['signed_prekey_pub'] ?? json['signed_prekey']?['key'], - signedPrekeySig: json['signed_prekey_sig'] ?? json['signed_prekey']?['signature'], - oneTimePrekeyPub: json['one_time_prekeys'][0] ?? json['one_time_prekey']?['key'], + signedPrekeyPub: + json['signed_prekey_pub'] ?? json['signed_prekey']?['key'], + signedPrekeySig: + json['signed_prekey_sig'] ?? json['signed_prekey']?['signature'], + oneTimePrekeyPub: + json['one_time_prekeys'][0] ?? json['one_time_prekey']?['key'], + ); + } + + // Federated endpoint returns a slightly different shape: + // device_id, identity_pubkey, signed_prekey_pub, signed_prekey_sig, + // one_time_prekeys: ["base64", ...] (strings, not objects) + factory UserDevice.fromFederatedJson(Map json) { + final otpks = json['one_time_prekeys']; + String? otpk; + if (otpks is List && otpks.isNotEmpty) { + final first = otpks[0]; + otpk = first is String + ? first + : (first is Map ? first['key'] as String? : null); + } + return UserDevice( + deviceId: json['device_id'] ?? json['id'], + identityPubkey: json['identity_pubkey'], + prekeyPubkey: json['prekey_pubkey'], + signedPrekeyPub: json['signed_prekey_pub'], + signedPrekeySig: json['signed_prekey_sig'], + oneTimePrekeyPub: otpk, ); } Map toJson() => { - 'device_id': deviceId, - 'prekey_pubkey': prekeyPubkey, - 'identity_pubkey': identityPubkey, - 'signed_prekey_pub': signedPrekeyPub, - 'signed_prekey_sig': signedPrekeySig, - 'one_time_prekey_pub': oneTimePrekeyPub, - }; + 'device_id': deviceId, + 'prekey_pubkey': prekeyPubkey, + 'identity_pubkey': identityPubkey, + 'signed_prekey_pub': signedPrekeyPub, + 'signed_prekey_sig': signedPrekeySig, + 'one_time_prekey_pub': oneTimePrekeyPub, + }; @override String toString() => jsonEncode(toJson()); diff --git a/lib/screens/chat_view_screen.dart b/lib/screens/chat_view_screen.dart index c061b72..1643989 100644 --- a/lib/screens/chat_view_screen.dart +++ b/lib/screens/chat_view_screen.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:hushnet_frontend/data/node/sessions/create_session.dart'; +import 'package:flutter/services.dart'; import 'package:hushnet_frontend/models/chat_view.dart'; import 'package:hushnet_frontend/models/message_view.dart'; import 'package:hushnet_frontend/services/key_provider.dart'; @@ -29,24 +29,36 @@ class ChatViewScreen extends StatefulWidget { class _ChatViewScreenState extends State { final MessageService messageService = MessageService(); final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollCtrl = ScrollController(); + final FocusNode _inputFocus = FocusNode(); List _messages = []; - bool _loading = true; final NodeService _nodeService = NodeService(); String? _currentUserId; + bool _wsConnected = true; + StreamSubscription? _connSub; final StreamController> _messageStreamController = StreamController>.broadcast(); + bool get _isRemote => widget.chatView.isRemote; + @override void initState() { super.initState(); + + _wsConnected = _nodeService.isConnected; + _connSub = _nodeService.connectionState.listen((connected) { + if (!mounted) return; + setState(() => _wsConnected = connected); + }); + _nodeService.getCurrentUserId().then((id) { - setState(() { - _currentUserId = id; - }); + if (!mounted) return; + setState(() => _currentUserId = id); }); _loadMessages().then((_) { _messageStreamController.add(_messages); + _scrollToBottom(); }); _nodeService.connectWebSocket().then((_) { @@ -59,6 +71,7 @@ class _ChatViewScreenState extends State { ); _messages = newMessages; _messageStreamController.add(_messages); + _scrollToBottom(); } }); }); @@ -66,23 +79,33 @@ class _ChatViewScreenState extends State { @override void dispose() { + _connSub?.cancel(); _controller.dispose(); + _scrollCtrl.dispose(); + _inputFocus.dispose(); _messageStreamController.close(); super.dispose(); } + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollCtrl.hasClients) { + _scrollCtrl.animateTo( + _scrollCtrl.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + }); + } + Future _loadMessages() async { try { - setState(() => _loading = true); final all = await messageService.getAllMessagesForChat(widget.chatId); - // 🕒 tri croissant (vieux → récents) DateTime normalize(DateTime d) { final s = d.toIso8601String(); - print("normalizing date string: $s"); - // Si la date n'a pas de "Z" ni d'offset, on la traite comme locale et on force en UTC if (!s.endsWith('Z') && !s.contains('+')) { - print("manque zone info, normalizing to UTC for $s"); - final res = DateTime.utc( + return DateTime.utc( d.year, d.month, d.day, @@ -92,8 +115,6 @@ class _ChatViewScreenState extends State { d.millisecond, d.microsecond, ).toUtc(); - print("normalized date: ${res.toIso8601String()}"); - return res; } return d.toUtc(); } @@ -102,19 +123,11 @@ class _ChatViewScreenState extends State { msg.createdAt = normalize(msg.createdAt); } all.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - for (final msg in all) { - print( - 'Message from ${msg.fromUserId}: ${msg.ciphertext} at ${msg.createdAt}', - ); - } - setState(() { - _messages = all; - _loading = false; - }); + if (!mounted) return; + setState(() => _messages = all); } catch (e) { debugPrint("Error loading messages: $e"); - setState(() => _loading = false); } } @@ -122,40 +135,63 @@ class _ChatViewScreenState extends State { final text = _controller.text.trim(); if (text.isEmpty || _currentUserId == null) return; + _controller.clear(); + _inputFocus.requestFocus(); + try { final keyProvider = KeyProvider(); - - // 1️⃣ Identifier le destinataire final recipientUserId = widget.chatView.partnerUserId!; - // 2️⃣ Récupérer les devices actifs du destinataire - final devices = await keyProvider.getUserDevicesKeys(recipientUserId); + final devices = _isRemote + ? await keyProvider.getRemoteUserDevicesKeys( + widget.chatView.federatedAddress!, + ) + : await keyProvider.getUserDevicesKeys(recipientUserId); + if (devices.isEmpty) { debugPrint('No devices for recipient'); return; } - // 5️⃣ Envoi du message - MessageView sentMsg = await messageService.sendMessage( + final MessageView sentMsg = await messageService.sendMessage( chatId: widget.chatId, plaintext: text, recipientUserId: recipientUserId, recipientDeviceIds: devices.map((d) => d.deviceId).toList(), + toUserAddress: widget.chatView.federatedAddress, ); _messages.add(sentMsg); - _messageStreamController.add( - List.from(_messages), - ); // push nouveau snapshot - _controller.clear(); - } catch (e) { - debugPrint("❌ Error sending message: $e"); + _messageStreamController.add(List.from(_messages)); + _scrollToBottom(); + } on Exception catch (e) { + debugPrint("Error sending message: $e"); + if (!mounted) return; + final msg = e.toString(); + String userMsg; + if (msg.contains('HTTP 400')) { + userMsg = "Invalid address format"; + } else if (msg.contains('HTTP 403')) { + userMsg = "Node unavailable"; + } else if (msg.contains('HTTP 404')) { + userMsg = "User not found"; + } else if (msg.contains('HTTP 502') || msg.contains('HTTP 503')) { + userMsg = "Delivery pending — will retry automatically"; + } else { + userMsg = "Failed to send message"; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.redAccent, + content: Text(userMsg, style: const TextStyle(color: Colors.white)), + ), + ); } } @override Widget build(BuildContext context) { - Widget _infoRow(String title, String value) { + Widget infoRow(String title, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( @@ -177,89 +213,78 @@ class _ChatViewScreenState extends State { ); } - void _showMessageInfo(BuildContext context, MessageView msg) { + void showMessageInfo(BuildContext ctx, MessageView msg) { showModalBottomSheet( - context: context, + context: ctx, backgroundColor: const Color(0xFF1C1C1C), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) { - debugPrint(msg.toJson().toString()); - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "🔒 Message Encryption Info", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.greenAccent, - ), + builder: (_) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "🔒 Message Encryption Info", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.greenAccent, ), - const SizedBox(height: 12), - _infoRow("Message ID", msg.id ?? "unknown"), - _infoRow("From", msg.fromUserId ?? "unknown"), - _infoRow("Created at", msg.createdAt.toIso8601String()), - const Divider(color: Colors.grey), - const SizedBox(height: 6), - _infoRow("Algorithm", "AES-256-GCM"), - _infoRow("Key Exchange", "X3DH + Double Ratchet"), - if (msg.fromDeviceId == "SELF_DEVICE") - _infoRow( - "Local Ciphertext Length", - "${msg.localCiphertext!.toString().length} bytes", - ), - if (msg.fromDeviceId == "SELF_DEVICE") - _infoRow("Local Ciphertext", msg.localCiphertext.toString()), - const Divider(color: Colors.grey), - if (msg.fromDeviceId != "SELF_DEVICE") - _infoRow( - "Ciphertext Length", - "${msg.ciphertext.length} bytes", - ), - if (msg.fromDeviceId != "SELF_DEVICE") - _infoRow( - "Ciphertext (bytes)", - base64Decode(msg.ciphertext).toString(), - ), - _infoRow("Session ID", widget.chatId), - const SizedBox(height: 12), - Center( - child: TextButton.icon( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close, color: Colors.grey), - label: const Text( - "Close", - style: TextStyle(color: Colors.grey), - ), + ), + const SizedBox(height: 12), + infoRow("Message ID", msg.id), + infoRow("From", msg.fromUserId), + infoRow("Created at", msg.createdAt.toIso8601String()), + const Divider(color: Colors.grey), + const SizedBox(height: 6), + infoRow("Algorithm", "AES-256-GCM"), + infoRow("Key Exchange", "X3DH + Double Ratchet"), + if (_isRemote) + infoRow( + "Remote node", + widget.chatView.federatedAddress!.split('@').last, + ), + if (msg.fromDeviceId == "SELF_DEVICE") + infoRow( + "Local Ciphertext Length", + "${msg.localCiphertext.toString().length} bytes", + ), + if (msg.fromDeviceId == "SELF_DEVICE") + infoRow("Local Ciphertext", msg.localCiphertext.toString()), + const Divider(color: Colors.grey), + if (msg.fromDeviceId != "SELF_DEVICE") + infoRow("Ciphertext Length", "${msg.ciphertext.length} bytes"), + if (msg.fromDeviceId != "SELF_DEVICE") + infoRow( + "Ciphertext (bytes)", + base64Decode(msg.ciphertext).toString(), + ), + infoRow("Session ID", widget.chatId), + const SizedBox(height: 12), + Center( + child: TextButton.icon( + onPressed: () => Navigator.pop(ctx), + icon: const Icon(Icons.close, color: Colors.grey), + label: const Text( + "Close", + style: TextStyle(color: Colors.grey), ), ), - ], - ), - ); - }, + ), + ], + ), + ), ); } - final isDesktop = MediaQuery.of(context).size.width > 800; - final chatBody = Column( children: [ - if (!widget.embedded) - AppBar( - backgroundColor: const Color(0xFF1C1C1C), - title: Text(widget.displayName), - actions: [ - IconButton( - icon: const Icon(Icons.refresh, color: Colors.greenAccent), - onPressed: _loadMessages, - ), - ], - ), + if (!widget.embedded) _buildAppBar(context), + if (_isRemote) _buildRemoteBanner(), + _buildWsBanner(), Expanded( child: StreamBuilder>( stream: _messageStreamController.stream, @@ -274,107 +299,273 @@ class _ChatViewScreenState extends State { if (messages.isEmpty) { return const Center( child: Text( - "No messages yet 💬", + "No messages yet", style: TextStyle(color: Colors.grey), ), ); } return ListView.builder( - padding: const EdgeInsets.all(12), + controller: _scrollCtrl, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 16, + ), itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[index]; final isMe = msg.fromUserId == _currentUserId; - return Align( - alignment: isMe - ? Alignment.centerRight - : Alignment.centerLeft, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - textDirection: isMe - ? TextDirection.rtl - : TextDirection.ltr, - children: [ - Container( - margin: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 4, - ), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), - decoration: BoxDecoration( - color: isMe - ? Colors.greenAccent - : const Color(0xFF2A2A2A), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - msg.decryptedText, - style: TextStyle( - color: isMe ? Colors.black : Colors.white, - ), - ), - ), - IconButton( - icon: Icon( - Icons.info_outline, - color: isMe ? Colors.greenAccent : Colors.grey[400], - size: 18, - ), - onPressed: () => _showMessageInfo(context, msg), - ), - ], - ), + return _buildMessageBubble( + context, + msg, + isMe, + () => showMessageInfo(context, msg), ); }, ); }, ), ), + _buildInputBar(), + ], + ); - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: const BoxDecoration( - color: Color(0xFF1C1C1C), - border: Border( - top: BorderSide(color: Color(0xFF2F2F2F), width: 0.5), + if (widget.embedded) { + return Container(color: const Color(0xFF101010), child: chatBody); + } + + return Scaffold( + backgroundColor: const Color(0xFF101010), + body: SafeArea(child: chatBody), + ); + } + + Widget _buildMessageBubble( + BuildContext context, + MessageView msg, + bool isMe, + VoidCallback onInfo, + ) { + final maxBubbleWidth = MediaQuery.of(context).size.width * 0.72; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe) + IconButton( + icon: Icon(Icons.info_outline, color: Colors.grey[600], size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: onInfo, ), - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - hintText: "Type a message...", - hintStyle: TextStyle(color: Colors.grey[500]), - border: InputBorder.none, - ), + const SizedBox(width: 4), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxBubbleWidth), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isMe ? Colors.greenAccent : const Color(0xFF2A2A2A), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isMe ? 16 : 4), + bottomRight: Radius.circular(isMe ? 4 : 16), ), ), - IconButton( - icon: const Icon(Icons.send, color: Colors.greenAccent), - onPressed: _sendMessage, + child: SelectableText( + msg.decryptedText, + style: TextStyle( + color: isMe ? Colors.black : Colors.white, + fontSize: 14, + height: 1.4, + ), ), - ], + ), ), + const SizedBox(width: 4), + if (isMe) + IconButton( + icon: Icon( + Icons.info_outline, + color: Colors.greenAccent.withValues(alpha: 0.5), + size: 16, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: onInfo, + ), + ], + ), + ); + } + + Widget _buildWsBanner() { + if (_wsConnected) return const SizedBox.shrink(); + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + color: const Color(0xFF2A1A00), + child: Row( + children: [ + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.orange, + ), + ), + const SizedBox(width: 10), + const Text( + "Reconnecting…", + style: TextStyle(color: Colors.orange, fontSize: 12), + ), + ], + ), + ); + } + + PreferredSizeWidget _buildAppBar(BuildContext context) { + return AppBar( + backgroundColor: const Color(0xFF1C1C1C), + title: Row( + children: [ + Text(widget.displayName), + if (_isRemote) ...[ + const SizedBox(width: 8), + _remoteChip(small: true), + ], + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh, color: Colors.greenAccent), + onPressed: () => _loadMessages().then((_) { + _messageStreamController.add(_messages); + _scrollToBottom(); + }), ), ], ); + } - if (widget.embedded) { - return Container(color: const Color(0xFF101010), child: chatBody); - } + Widget _buildRemoteBanner() { + final nodeHost = widget.chatView.federatedAddress!.split('@').last; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + color: const Color(0xFF0D1B2A), + child: Row( + children: [ + const Icon(Icons.public, color: Color(0xFF3A8DFF), size: 15), + const SizedBox(width: 8), + Expanded( + child: Text( + "External node: $nodeHost", + style: const TextStyle( + color: Color(0xFF3A8DFF), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Tooltip( + message: + "End-to-end encrypted across nodes.\nYour keys never leave your device.", + child: const Icon(Icons.lock, color: Color(0xFF3A8DFF), size: 14), + ), + ], + ), + ); + } - return Scaffold( - backgroundColor: const Color(0xFF101010), - body: SafeArea(child: chatBody), + Widget _remoteChip({bool small = false}) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: small ? 6 : 8, + vertical: small ? 2 : 4, + ), + decoration: BoxDecoration( + color: const Color(0xFF3A8DFF).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFF3A8DFF).withValues(alpha: 0.4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.public, + color: const Color(0xFF3A8DFF), + size: small ? 11 : 13, + ), + const SizedBox(width: 3), + Text( + "External", + style: TextStyle( + color: const Color(0xFF3A8DFF), + fontSize: small ? 10 : 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + Widget _buildInputBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: const BoxDecoration( + color: Color(0xFF1C1C1C), + border: Border(top: BorderSide(color: Color(0xFF2F2F2F), width: 0.5)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.enter): _sendMessage, + }, + child: TextField( + controller: _controller, + focusNode: _inputFocus, + style: const TextStyle(color: Colors.white, fontSize: 14), + minLines: 1, + maxLines: 6, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: _isRemote + ? "Message ${widget.chatView.federatedAddress}…" + : "Type a message…", + hintStyle: TextStyle(color: Colors.grey[600], fontSize: 14), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 8, + ), + ), + ), + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.send_rounded, color: Colors.greenAccent), + onPressed: _sendMessage, + tooltip: "Send (Enter)", + ), + ], + ), ); } } diff --git a/lib/screens/conversations_screen.dart b/lib/screens/conversations_screen.dart index 243f9ca..479f51e 100644 --- a/lib/screens/conversations_screen.dart +++ b/lib/screens/conversations_screen.dart @@ -3,6 +3,7 @@ import 'package:hushnet_frontend/data/node/sessions/create_session.dart'; import 'package:hushnet_frontend/models/chat_view.dart'; import 'package:hushnet_frontend/screens/chat_view_screen.dart'; import 'package:hushnet_frontend/screens/pending_sessions_screen.dart'; +import 'package:hushnet_frontend/screens/settings_screen.dart'; import 'package:hushnet_frontend/screens/user_list_screen.dart'; import 'package:hushnet_frontend/services/chat_service.dart'; import 'package:hushnet_frontend/services/node_service.dart'; @@ -156,14 +157,25 @@ class _ConversationsScreenState extends State { const Spacer(), IconButton( onPressed: () { - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const UserListScreen())); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => UserListScreen( + existingPartnerIds: _chats + .map((c) => c.partnerUserId) + .whereType() + .toSet(), + ), + ), + ); }, icon: const Icon(Icons.add, color: Colors.white), ), IconButton( - onPressed: () {}, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ); + }, icon: const Icon(Icons.settings, color: Colors.white), ), IconButton( @@ -194,7 +206,7 @@ class _ConversationsScreenState extends State { decoration: BoxDecoration( color: const Color(0xFF222222), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.greenAccent.withOpacity(0.3)), + border: Border.all(color: Colors.greenAccent.withValues(alpha: 0.3)), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -260,17 +272,62 @@ class _ConversationsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - chat.displayName, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), + Row( + children: [ + Flexible( + child: Text( + chat.displayName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (chat.isRemote) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFF3A8DFF).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: const Color(0xFF3A8DFF).withValues(alpha: 0.4), + ), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.public, color: Color(0xFF3A8DFF), size: 10), + SizedBox(width: 2), + Text( + "ext", + style: TextStyle( + color: Color(0xFF3A8DFF), + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ], ), const SizedBox(height: 4), Text( - chat.previewText, - style: TextStyle(color: Colors.grey[400]), + chat.isRemote + ? chat.federatedAddress! + : chat.previewText, + style: TextStyle( + color: chat.isRemote + ? const Color(0xFF3A8DFF).withValues(alpha: 0.8) + : Colors.grey[400], + fontSize: 12, + ), overflow: TextOverflow.ellipsis, ), ], diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..bd8939d --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,559 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:hushnet_frontend/screens/onboarding.dart'; +import 'package:hushnet_frontend/services/node_service.dart'; +import 'package:hushnet_frontend/services/secure_storage_service.dart'; +import 'package:url_launcher/url_launcher.dart'; + +const _kAppVersion = '1.0.0'; +const _kBuildNumber = '1'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + final NodeService _nodeService = NodeService(); + final SecureStorageService _storage = SecureStorageService(); + + String? _username; + String? _userId; + String? _deviceId; + String? _nodeUrl; + bool _wsConnected = false; + bool _pinging = false; + String? _pingResult; + + @override + void initState() { + super.initState(); + _load(); + _wsConnected = _nodeService.isConnected; + _nodeService.connectionState.listen((v) { + if (!mounted) return; + setState(() => _wsConnected = v); + }); + } + + Future _load() async { + final results = await Future.wait([ + _storage.read('username'), + _storage.read('user_id'), + _storage.read('device_id'), + _storage.read('node_url'), + ]); + if (!mounted) return; + setState(() { + _username = results[0]; + _userId = results[1]; + _deviceId = results[2]; + _nodeUrl = results[3]; + }); + } + + Future _ping() async { + setState(() { + _pinging = true; + _pingResult = null; + }); + final sw = Stopwatch()..start(); + try { + await _nodeService.connectWebSocket(); + sw.stop(); + setState(() => _pingResult = '${sw.elapsedMilliseconds} ms'); + } catch (_) { + setState(() => _pingResult = 'unreachable'); + } finally { + setState(() => _pinging = false); + } + } + + Future _launch(String url) async { + final uri = Uri.parse(url); + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + if (!mounted) return; + _copy(url, 'Link'); + } + } + + void _copy(String value, String label) { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: const Color(0xFF2A2A2A), + content: Text( + '$label copied', + style: GoogleFonts.inter(color: Colors.white70, fontSize: 13), + ), + duration: const Duration(seconds: 1), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ); + } + + Future _signOut() async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text( + 'Sign out?', + style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.w600), + ), + content: Text( + 'All local keys and session data will be cleared. This cannot be undone.', + style: GoogleFonts.inter(color: Colors.white60), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text('Cancel', style: GoogleFonts.inter(color: Colors.grey)), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text('Sign out', style: GoogleFonts.inter(color: Colors.white)), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + _nodeService.disconnectWebSocket(); + await _storage.clear(); + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const OnboardingScreen()), + (_) => false, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0D0D0D), + appBar: AppBar( + backgroundColor: const Color(0xFF0D0D0D), + elevation: 0, + title: Text( + 'Settings', + style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.w600), + ), + centerTitle: true, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + _buildProfile(), + const SizedBox(height: 16), + _buildSection( + icon: Icons.dns_outlined, + title: 'Node', + color: const Color(0xFF3A8DFF), + children: [ + _buildInfoTile( + label: 'URL', + value: _nodeUrl ?? '—', + onCopy: _nodeUrl != null ? () => _copy(_nodeUrl!, 'Node URL') : null, + ), + _buildStatusTile(), + _buildPingTile(), + ], + ), + const SizedBox(height: 16), + _buildSection( + icon: Icons.lock_outline, + title: 'Security', + color: Colors.greenAccent, + children: [ + _buildInfoTile(label: 'Encryption', value: 'AES-256-GCM'), + _buildInfoTile(label: 'Key exchange', value: 'X3DH + Double Ratchet'), + _buildInfoTile(label: 'Key storage', value: 'Local device only'), + _buildInfoTile(label: 'Forward secrecy', value: 'Enabled'), + ], + ), + const SizedBox(height: 16), + _buildSection( + icon: Icons.info_outline, + title: 'About', + color: Colors.white54, + children: [ + _buildInfoTile(label: 'Version', value: '$_kAppVersion (build $_kBuildNumber)'), + _buildInfoTile(label: 'Made in', value: 'Marseille 🇫🇷'), + _buildLinkTile( + label: 'HushNet', + url: 'https://github.com/HushNet', + ), + _buildLinkTile( + label: 'Frontend', + url: 'https://github.com/HushNet/HushNet-Frontend', + ), + _buildLinkTile( + label: 'Backend', + url: 'https://github.com/HushNet/HushNet-Backend', + ), + ], + ), + const SizedBox(height: 24), + _buildDangerZone(), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildProfile() { + final initial = (_username ?? '?')[0].toUpperCase(); + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFF2A2A2A)), + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.greenAccent.withValues(alpha: 0.15), + shape: BoxShape.circle, + border: Border.all(color: Colors.greenAccent.withValues(alpha: 0.4)), + ), + child: Center( + child: Text( + initial, + style: GoogleFonts.inter( + color: Colors.greenAccent, + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _username ?? '—', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + GestureDetector( + onTap: _userId != null ? () => _copy(_userId!, 'User ID') : null, + child: Text( + _truncate(_userId, 28), + style: GoogleFonts.inter(color: Colors.grey, fontSize: 12), + ), + ), + if (_deviceId != null) ...[ + const SizedBox(height: 2), + GestureDetector( + onTap: () => _copy(_deviceId!, 'Device ID'), + child: Row( + children: [ + const Icon(Icons.phone_android, color: Colors.grey, size: 11), + const SizedBox(width: 4), + Text( + _truncate(_deviceId, 24), + style: GoogleFonts.inter(color: Colors.grey, fontSize: 11), + ), + ], + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildSection({ + required IconData icon, + required String title, + required Color color, + required List children, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Row( + children: [ + Icon(icon, color: color, size: 15), + const SizedBox(width: 6), + Text( + title.toUpperCase(), + style: GoogleFonts.inter( + color: color, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF2A2A2A)), + ), + child: Column(children: children), + ), + ], + ); + } + + Widget _buildInfoTile({ + required String label, + required String value, + VoidCallback? onCopy, + }) { + return InkWell( + onTap: onCopy, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: GoogleFonts.inter(color: Colors.grey, fontSize: 13), + ), + ), + Flexible( + flex: 2, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: Text( + value, + textAlign: TextAlign.right, + style: GoogleFonts.inter(color: Colors.white, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + if (onCopy != null) ...[ + const SizedBox(width: 6), + const Icon(Icons.copy, color: Colors.grey, size: 13), + ], + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildLinkTile({required String label, required String url}) { + return InkWell( + onTap: () => _launch(url), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: GoogleFonts.inter(color: Colors.grey, fontSize: 13), + ), + ), + Flexible( + flex: 2, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: Text( + url.replaceFirst('https://', ''), + textAlign: TextAlign.right, + style: GoogleFonts.inter( + color: const Color(0xFF3A8DFF), + fontSize: 13, + decoration: TextDecoration.underline, + decorationColor: const Color(0xFF3A8DFF), + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + const Icon(Icons.open_in_new, color: Color(0xFF3A8DFF), size: 13), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildStatusTile() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Expanded( + child: Text( + 'WebSocket', + style: GoogleFonts.inter(color: Colors.grey, fontSize: 13), + ), + ), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _wsConnected ? Colors.greenAccent : Colors.orange, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: (_wsConnected ? Colors.greenAccent : Colors.orange) + .withValues(alpha: 0.5), + blurRadius: 4, + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + _wsConnected ? 'Connected' : 'Reconnecting…', + style: GoogleFonts.inter( + color: _wsConnected ? Colors.greenAccent : Colors.orange, + fontSize: 13, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildPingTile() { + return InkWell( + onTap: _pinging ? null : _ping, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Expanded( + child: Text( + 'Ping node', + style: GoogleFonts.inter(color: Colors.grey, fontSize: 13), + ), + ), + if (_pinging) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.white38, + ), + ) + else if (_pingResult != null) + Text( + _pingResult!, + style: GoogleFonts.inter( + color: _pingResult == 'unreachable' ? Colors.redAccent : Colors.greenAccent, + fontSize: 13, + ), + ) + else + const Icon(Icons.chevron_right, color: Colors.grey, size: 18), + ], + ), + ), + ); + } + + Widget _buildDangerZone() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Row( + children: [ + const Icon(Icons.warning_amber_outlined, color: Colors.redAccent, size: 15), + const SizedBox(width: 6), + Text( + 'DANGER ZONE', + style: GoogleFonts.inter( + color: Colors.redAccent, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.redAccent.withValues(alpha: 0.25)), + ), + child: InkWell( + onTap: _signOut, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + const Icon(Icons.logout, color: Colors.redAccent, size: 18), + const SizedBox(width: 12), + Text( + 'Sign out', + style: GoogleFonts.inter( + color: Colors.redAccent, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ], + ); + } + + String _truncate(String? s, int max) { + if (s == null) return '—'; + if (s.length <= max) return s; + return '${s.substring(0, max)}…'; + } +} diff --git a/lib/screens/user_list_screen.dart b/lib/screens/user_list_screen.dart index bd672e0..5c3b9cf 100644 --- a/lib/screens/user_list_screen.dart +++ b/lib/screens/user_list_screen.dart @@ -3,36 +3,57 @@ import 'package:hushnet_frontend/data/node/sessions/create_session.dart'; import 'package:hushnet_frontend/data/node/users/fetch_users.dart'; import 'package:hushnet_frontend/models/users.dart'; import 'package:hushnet_frontend/services/node_service.dart'; +import 'package:hushnet_frontend/utils/federation.dart'; import 'package:google_fonts/google_fonts.dart'; class UserListScreen extends StatefulWidget { - const UserListScreen({super.key}); + final Set existingPartnerIds; + + const UserListScreen({super.key, this.existingPartnerIds = const {}}); @override State createState() => _UserListScreenState(); } -class _UserListScreenState extends State { +class _UserListScreenState extends State + with SingleTickerProviderStateMixin { final TextEditingController _searchCtrl = TextEditingController(); + final TextEditingController _federatedCtrl = TextEditingController(); final NodeService _nodeService = NodeService(); + late final TabController _tabCtrl; + List _allUsers = []; List _filteredUsers = []; bool _loading = true; + bool _federatedLoading = false; + String? _federatedError; @override void initState() { super.initState(); + _tabCtrl = TabController(length: 2, vsync: this); _fetchUsers(); _searchCtrl.addListener(_filterUsers); } + @override + void dispose() { + _tabCtrl.dispose(); + _searchCtrl.dispose(); + _federatedCtrl.dispose(); + super.dispose(); + } + Future _fetchUsers() async { try { final nodeUrl = await _nodeService.getCurrentNodeUrl(); final users = await fetchUsers(nodeUrl!); - // Exclude self from user list final userId = await _nodeService.getCurrentUserId(); - users.removeWhere((user) => user.id == userId); + users.removeWhere( + (user) => + user.id == userId || + widget.existingPartnerIds.contains(user.id), + ); setState(() { _allUsers = users; _filteredUsers = users; @@ -54,20 +75,117 @@ class _UserListScreenState extends State { } Future _onUserTap(User user) async { - final confirm = await showDialog( + final confirm = await _showConfirmDialog( + title: "Start secure session", + body: "Create an encrypted session with ${user.username}?", + ); + if (confirm != true) return; + + final nodeUrl = await _nodeService.getCurrentNodeUrl(); + final success = await createSession(nodeUrl!, user.id); + if (!mounted) return; + _showResultSnackBar( + success: success, + successMsg: "Session started with ${user.username}", + failMsg: "Failed to create session", + ); + } + + Future _onFederatedConnect() async { + final raw = _federatedCtrl.text.trim(); + final addr = FederatedAddress.tryParse(raw); + if (addr == null) { + setState(() => _federatedError = "Invalid address — use user@node format"); + return; + } + + final nodeUrl = await _nodeService.getCurrentNodeUrl(); + if (nodeUrl != null && addr.isLocal(nodeUrl)) { + setState(() => _federatedError = "That user is on your node — use the Local tab"); + return; + } + + final confirm = await _showConfirmDialog( + title: "Connect to ${addr.full}", + body: "Start an encrypted session with ${addr.full}?\n\nThis user is on an external HushNet node.", + isRemote: true, + ); + if (confirm != true) return; + + setState(() { + _federatedLoading = true; + _federatedError = null; + }); + + try { + // Nil UUID is ignored server-side for federated sessions + const placeholderUuid = '00000000-0000-0000-0000-000000000000'; + final success = await createSession( + nodeUrl!, + placeholderUuid, + recipientUserAddress: addr.full, + ); + if (!mounted) return; + if (success) { + _federatedCtrl.clear(); + _showResultSnackBar( + success: true, + successMsg: "Session request sent to ${addr.full}", + failMsg: '', + ); + } else { + setState(() => _federatedError = "Failed to reach ${addr.nodeHost}"); + } + } on Exception catch (e) { + if (!mounted) return; + final msg = e.toString(); + setState(() { + _federatedError = _mapFederatedError(msg); + }); + } finally { + if (mounted) setState(() => _federatedLoading = false); + } + } + + String _mapFederatedError(String raw) { + if (raw.contains('HTTP 400')) return "Invalid federated address format"; + if (raw.contains('HTTP 403')) return "Remote node is unavailable or blocked"; + if (raw.contains('HTTP 404')) return "User not found on remote node"; + if (raw.contains('HTTP 502') || raw.contains('HTTP 503')) { + return "Remote node unreachable — delivery will be retried automatically"; + } + return "Connection failed — check the address and try again"; + } + + Future _showConfirmDialog({ + required String title, + required String body, + bool isRemote = false, + }) { + return showDialog( context: context, builder: (_) => AlertDialog( backgroundColor: const Color(0xFF1E1E1E), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text( - "Start secure session", - style: GoogleFonts.inter( - color: Colors.white, - fontWeight: FontWeight.w600, - ), + title: Row( + children: [ + if (isRemote) ...[ + const Icon(Icons.public, color: Color(0xFF3A8DFF), size: 18), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + title, + style: GoogleFonts.inter( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), content: Text( - "Create an encrypted session with ${user.username}?", + body, style: GoogleFonts.inter(color: Colors.white70), ), actions: [ @@ -86,28 +204,27 @@ class _UserListScreenState extends State { borderRadius: BorderRadius.circular(8), ), ), - child: const Text("Create"), + child: const Text("Connect"), ), ], ), ); + } - if (confirm == true) { - final nodeUrl = await _nodeService.getCurrentNodeUrl(); - final success = await createSession(nodeUrl!, user.id); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: success ? const Color(0xFF3A8DFF) : Colors.redAccent, - content: Text( - success - ? "Session started with ${user.username}" - : "Failed to create session", - style: GoogleFonts.inter(color: Colors.white), - ), + void _showResultSnackBar({ + required bool success, + required String successMsg, + required String failMsg, + }) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: success ? const Color(0xFF3A8DFF) : Colors.redAccent, + content: Text( + success ? successMsg : failMsg, + style: GoogleFonts.inter(color: Colors.white), ), - ); - } + ), + ); } @override @@ -120,84 +237,180 @@ class _UserListScreenState extends State { appBar: AppBar( backgroundColor: const Color(0xFF0D0D0D), title: Text( - "Users", + "New conversation", style: GoogleFonts.inter( color: Colors.white, fontWeight: FontWeight.w600, ), ), centerTitle: true, + bottom: TabBar( + controller: _tabCtrl, + indicatorColor: const Color(0xFF3A8DFF), + labelColor: Colors.white, + unselectedLabelColor: Colors.grey, + tabs: const [ + Tab(icon: Icon(Icons.person, size: 18), text: "Local"), + Tab(icon: Icon(Icons.public, size: 18), text: "Federated"), + ], + ), ), body: SafeArea( child: Center( child: Container( constraints: BoxConstraints(maxWidth: maxWidth), - padding: const EdgeInsets.all(20), - child: Column( + child: TabBarView( + controller: _tabCtrl, children: [ - TextField( - controller: _searchCtrl, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - hintText: "Search user...", - prefixIcon: const Icon(Icons.search, color: Colors.grey), - ), - ), - const SizedBox(height: 16), - Expanded( - child: _loading - ? const Center( - child: CircularProgressIndicator( - color: Color(0xFF3A8DFF), - ), - ) - : _filteredUsers.isEmpty - ? Center( - child: Text( - "No users found", + _buildLocalTab(), + _buildFederatedTab(), + ], + ), + ), + ), + ), + ); + } + + Widget _buildLocalTab() { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + TextField( + controller: _searchCtrl, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + hintText: "Search user...", + prefixIcon: Icon(Icons.search, color: Colors.grey), + ), + ), + const SizedBox(height: 16), + Expanded( + child: _loading + ? const Center( + child: CircularProgressIndicator(color: Color(0xFF3A8DFF)), + ) + : _filteredUsers.isEmpty + ? Center( + child: Text( + "No users found", + style: GoogleFonts.inter( + color: Colors.white70, + fontSize: 16, + ), + ), + ) + : ListView.builder( + itemCount: _filteredUsers.length, + itemBuilder: (_, i) { + final user = _filteredUsers[i]; + return Card( + color: const Color(0xFF1E1E1E), + margin: const EdgeInsets.symmetric(vertical: 6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + child: ListTile( + onTap: () => _onUserTap(user), + title: Text( + user.username, style: GoogleFonts.inter( - color: Colors.white70, + color: Colors.white, fontSize: 16, ), ), - ) - : ListView.builder( - itemCount: _filteredUsers.length, - itemBuilder: (_, i) { - final user = _filteredUsers[i]; - return Card( - color: const Color(0xFF1E1E1E), - margin: const EdgeInsets.symmetric(vertical: 6), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: ListTile( - onTap: () => _onUserTap(user), - title: Text( - user.username, - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 16, - ), - ), - leading: const Icon( - Icons.person, - color: Color(0xFF3A8DFF), - ), - trailing: const Icon( - Icons.arrow_forward_ios, - color: Colors.white24, - size: 16, - ), - ), - ); - }, + leading: const Icon( + Icons.person, + color: Color(0xFF3A8DFF), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + color: Colors.white24, + size: 16, + ), ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildFederatedTab() { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF1A1F2E), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF3A8DFF).withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.public, color: Color(0xFF3A8DFF), size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + "Connect to a user on another HushNet node using their full address, e.g. alice@node-a.hushnet.net", + style: GoogleFonts.inter( + color: Colors.white70, + fontSize: 13, + ), + ), ), ], ), ), - ), + const SizedBox(height: 20), + TextField( + controller: _federatedCtrl, + style: const TextStyle(color: Colors.white), + keyboardType: TextInputType.emailAddress, + autocorrect: false, + onSubmitted: (_) => _onFederatedConnect(), + decoration: InputDecoration( + hintText: "user@node-host.example.com", + prefixIcon: const Icon(Icons.alternate_email, color: Colors.grey), + errorText: _federatedError, + errorMaxLines: 3, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _federatedLoading ? null : _onFederatedConnect, + icon: _federatedLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.link, size: 18), + label: Text( + _federatedLoading ? "Connecting..." : "Connect", + style: GoogleFonts.inter(fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF3A8DFF), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + ], ), ); } diff --git a/lib/services/key_provider.dart b/lib/services/key_provider.dart index 94c581b..1de54f7 100644 --- a/lib/services/key_provider.dart +++ b/lib/services/key_provider.dart @@ -226,6 +226,35 @@ class KeyProvider { } } + // Fetches key bundles for a remote federated user via the local node proxy. + // Uses the signed-request path so auth headers are included. + Future> getRemoteUserDevicesKeys( + String federatedAddress, + ) async { + final nodeUrl = await _storage.read(key: 'node_url'); + try { + final atIndex = federatedAddress.lastIndexOf('@'); + final username = federatedAddress.substring(0, atIndex); + final nodeId = federatedAddress.substring(atIndex + 1); + final response = await sendSignedRequest( + 'GET', + '$nodeUrl/s2s/federated/$username/$nodeId/keys', + ); + final data = response.data; + final List devicesJson = (data is List) ? data : (data['devices'] ?? []); + return devicesJson + .map((json) => UserDevice.fromFederatedJson(json)) + .toList(); + } on DioException catch (e) { + debugPrint("Error fetching remote user devices: $e"); + final status = e.response?.statusCode ?? 0; + final message = e.response?.data ?? e.message; + throw Exception( + 'Failed to fetch remote user keys (HTTP $status): $message ${e.requestOptions.uri} ${e.requestOptions.method}', + ); + } + } + Future sendSignedRequest( String method, String url, { @@ -285,7 +314,6 @@ class KeyProvider { // 🔐 DOUBLE RATCHET SESSION MANAGEMENT (with auto-update) // ================================================================ - /// Derive the next chain key from the current one using HKDF. Future _deriveNextChainKey(SecretKey currentKey) async { final bytes = await currentKey.extractBytes(); @@ -297,10 +325,16 @@ class KeyProvider { } /// Retrieve all session keys for a given peer - Future> getRatchetSessionKeys(String peerDeviceId) async { + Future> getRatchetSessionKeys( + String peerDeviceId, + ) async { final rootB64 = await _storage.read(key: "session_${peerDeviceId}_root"); - final sendB64 = await _storage.read(key: "session_${peerDeviceId}_send_chain"); - final recvB64 = await _storage.read(key: "session_${peerDeviceId}_recv_chain"); + final sendB64 = await _storage.read( + key: "session_${peerDeviceId}_send_chain", + ); + final recvB64 = await _storage.read( + key: "session_${peerDeviceId}_recv_chain", + ); if (rootB64 == null || sendB64 == null || recvB64 == null) { throw Exception("Missing ratchet session for $peerDeviceId"); @@ -361,7 +395,10 @@ class KeyProvider { } /// Decrypts and automatically updates the recv chain key - Future decryptMessage(String ciphertextB64, String peerDeviceId) async { + Future decryptMessage( + String ciphertextB64, + String peerDeviceId, + ) async { final keys = await getRatchetSessionKeys(peerDeviceId); final recvKey = keys["recv"]!; @@ -385,8 +422,9 @@ class KeyProvider { /// Get ratchet public/private pair Future getLocalRatchetPub(String peerDeviceId) async { - final ratchetPubB64 = - await _storage.read(key: "session_${peerDeviceId}_ratchet_pub"); + final ratchetPubB64 = await _storage.read( + key: "session_${peerDeviceId}_ratchet_pub", + ); if (ratchetPubB64 == null) { throw Exception("Missing local ratchet pub for $peerDeviceId"); } @@ -394,11 +432,12 @@ class KeyProvider { } Future getLocalRatchetPriv(String peerDeviceId) async { - final ratchetPrivB64 = - await _storage.read(key: "session_${peerDeviceId}_ratchet_priv"); + final ratchetPrivB64 = await _storage.read( + key: "session_${peerDeviceId}_ratchet_priv", + ); if (ratchetPrivB64 == null) { throw Exception("Missing local ratchet priv for $peerDeviceId"); } return base64Decode(ratchetPrivB64); } -} \ No newline at end of file +} diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart index db84423..b7afae3 100644 --- a/lib/services/message_service.dart +++ b/lib/services/message_service.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:hushnet_frontend/models/chat_view.dart'; import 'package:hushnet_frontend/models/message_view.dart'; import 'package:hushnet_frontend/services/key_provider.dart'; import 'package:hushnet_frontend/services/node_service.dart'; @@ -165,6 +164,7 @@ class MessageService { required String plaintext, required String recipientUserId, required List recipientDeviceIds, + String? toUserAddress, }) async { final nodeUrl = await nodeService.getCurrentNodeUrl(); final logicalMsgId = const Uuid().v4(); @@ -229,12 +229,12 @@ class MessageService { ); } - // 🔹 Corps complet pour le backend final payload = { "logical_msg_id": logicalMsgId, "chat_id": chatId, "to_user_id": recipientUserId, - "payloads": payloads, // ✅ LISTE, pas objet unique + if (toUserAddress != null) "to_user_address": toUserAddress, + "payloads": payloads, }; // 🔹 Envoi en une requête diff --git a/lib/services/node_service.dart b/lib/services/node_service.dart index 30caed7..17dfb8a 100644 --- a/lib/services/node_service.dart +++ b/lib/services/node_service.dart @@ -24,6 +24,14 @@ class NodeService { String? _connectedUserId; final _controller = StreamController>.broadcast(); + bool _isConnected = false; + bool _retrying = false; + int _retryDelay = 3; + final _connectionStateController = StreamController.broadcast(); + + Stream get connectionState => _connectionStateController.stream; + bool get isConnected => _isConnected; + Stream> get stream => _controller.stream; @@ -159,58 +167,68 @@ Future connectWebSocket() async { return; } if (_connectedUserId == userId && _channel != null) { - debugPrint("🔁 WebSocket already connected for $userId"); + debugPrint("WebSocket already connected for $userId"); return; } var clean = nodeUrl.trim().replaceAll('#', ''); final parsed = Uri.parse(clean); - - final scheme = (parsed.scheme == 'https' || parsed.scheme == 'wss') - ? 'wss' - : 'ws'; - + final scheme = (parsed.scheme == 'https' || parsed.scheme == 'wss') ? 'wss' : 'ws'; final host = parsed.host; final port = parsed.hasPort ? ':${parsed.port}' : ''; - final wsUrl = Uri.parse("$scheme://$host$port/ws/$userId"); - debugPrint("Connecting WS to $wsUrl with $userId"); + debugPrint("Connecting WS to $wsUrl"); + + void onDisconnect() { + _channel = null; + _connectedUserId = null; + _isConnected = false; + _connectionStateController.add(false); + _scheduleRetry(); + } try { _channel = WebSocketChannel.connect(wsUrl); - debugPrint("WS connected for $userId"); _connectedUserId = userId; + _isConnected = true; + _retryDelay = 3; + _connectionStateController.add(true); + debugPrint("WS connected for $userId"); + _subscription?.cancel(); _subscription = _channel!.stream.listen( (event) { try { final decoded = jsonDecode(event); _controller.add(decoded); - debugPrint("WS event: $decoded"); } catch (e) { debugPrint("Invalid WS payload: $e"); } }, onError: (err) { debugPrint("WS error: $err"); - _retry(nodeUrl, userId); + onDisconnect(); }, onDone: () { debugPrint("WS closed for $userId"); - _retry(nodeUrl, userId); + onDisconnect(); }, ); } catch (e) { debugPrint("Failed to connect WebSocket: $e"); - _retry(nodeUrl, userId); + onDisconnect(); } } - void _retry(String nodeUrl, String userId) { - Future.delayed(const Duration(seconds: 3), () { - if (_connectedUserId == userId) { - connectWebSocket(); - } + void _scheduleRetry() { + if (_retrying) return; + _retrying = true; + final delay = _retryDelay; + _retryDelay = (_retryDelay * 2).clamp(3, 60); + debugPrint("WS retry in ${delay}s"); + Future.delayed(Duration(seconds: delay), () { + _retrying = false; + connectWebSocket(); }); } diff --git a/lib/services/session_service.dart b/lib/services/session_service.dart index 5a9e4ce..3cca76a 100644 --- a/lib/services/session_service.dart +++ b/lib/services/session_service.dart @@ -123,14 +123,20 @@ class SessionService { ); final dh3Bytes = await dh3.extractBytes(); - // (optional) DH4 = DH(OPK_B, EK_A) + // (optional) DH4 = DH(OPK_B, EK_A) — must use the exact OPK Alice used List dh4Bytes = []; - if (oneTimePreKeys.isNotEmpty) { - final opk = oneTimePreKeys.first; + if (p.otpkUsed != null && p.otpkUsed!.isNotEmpty) { + final otpkPubBytes = base64Decode(p.otpkUsed!); + final matchingOpk = oneTimePreKeys.firstWhere( + (opk) => _bytesEqual(opk['public']!, otpkPubBytes), + orElse: () => throw Exception( + 'OPK used by sender (${p.otpkUsed}) not found in local store', + ), + ); final opkPair = SimpleKeyPairData( - opk['private']!, + matchingOpk['private']!, publicKey: SimplePublicKey( - opk['public']!, + matchingOpk['public']!, type: KeyPairType.x25519, ), type: KeyPairType.x25519, @@ -283,6 +289,14 @@ class SessionService { } } + bool _bytesEqual(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + Future hasSessionWithDevice(String deviceId) async { final root = await secureStorage.read("session_${deviceId}_root"); final send = await secureStorage.read("session_${deviceId}_send_chain"); diff --git a/lib/utils/federation.dart b/lib/utils/federation.dart new file mode 100644 index 0000000..62a336a --- /dev/null +++ b/lib/utils/federation.dart @@ -0,0 +1,30 @@ +class FederatedAddress { + final String username; + final String nodeHost; + + const FederatedAddress({required this.username, required this.nodeHost}); + + static FederatedAddress? tryParse(String address) { + final trimmed = address.trim(); + final atIndex = trimmed.indexOf('@'); + if (atIndex <= 0 || atIndex >= trimmed.length - 1) return null; + return FederatedAddress( + username: trimmed.substring(0, atIndex), + nodeHost: trimmed.substring(atIndex + 1), + ); + } + + bool isLocal(String nodeUrl) { + try { + final uri = Uri.parse(nodeUrl.trim()); + return uri.host.toLowerCase() == nodeHost.toLowerCase(); + } catch (_) { + return false; + } + } + + String get full => '$username@$nodeHost'; + + @override + String toString() => full; +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f79..38dd0bc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b29e9ba..65240e9 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 37af1fe..51aae31 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,9 +8,11 @@ import Foundation import flutter_secure_storage_macos import path_provider_foundation import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..e6d4149 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,36 @@ +PODS: + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + +EXTERNAL SOURCES: + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + +SPEC CHECKSUMS: + flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index b522298..f888e70 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + BB0CED08E943015E1982A9AC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2263B19E033D4A64F429E92A /* Pods_Runner.framework */; }; + BF7152C17200C13ECB055E3E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DF36DDFAE8648C67B3727BE /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 15E74DC7A939D885994E8EB1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 214FF5592588C2D7756B63C7 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 2263B19E033D4A64F429E92A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* hushnet_frontend.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "hushnet_frontend.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* hushnet_frontend.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = hushnet_frontend.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +81,13 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 49A1C7E9B9ECC407D58965DC /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 54EBE3A34B23408B393C3B15 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 898295B8E816FC86DD16E4B9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9DF36DDFAE8648C67B3727BE /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BBFB67BF9A4A9ECA0DFC72C3 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BF7152C17200C13ECB055E3E /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BB0CED08E943015E1982A9AC /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 6284F7E528B0AF82C41BFCB9 /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 6284F7E528B0AF82C41BFCB9 /* Pods */ = { + isa = PBXGroup; + children = ( + 54EBE3A34B23408B393C3B15 /* Pods-Runner.debug.xcconfig */, + 15E74DC7A939D885994E8EB1 /* Pods-Runner.release.xcconfig */, + BBFB67BF9A4A9ECA0DFC72C3 /* Pods-Runner.profile.xcconfig */, + 898295B8E816FC86DD16E4B9 /* Pods-RunnerTests.debug.xcconfig */, + 49A1C7E9B9ECC407D58965DC /* Pods-RunnerTests.release.xcconfig */, + 214FF5592588C2D7756B63C7 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 2263B19E033D4A64F429E92A /* Pods_Runner.framework */, + 9DF36DDFAE8648C67B3727BE /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 0E42C795662DFB7F69392EAF /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 75F55E8F762FA82FA74D3A7A /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 94B7CDDED80558AE0D806AB8 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -291,6 +323,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0E42C795662DFB7F69392EAF /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +383,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 75F55E8F762FA82FA74D3A7A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 94B7CDDED80558AE0D806AB8 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 898295B8E816FC86DD16E4B9 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 49A1C7E9B9ECC407D58965DC /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 214FF5592588C2D7756B63C7 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..8aea244 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,10 @@ com.apple.security.network.server + com.apple.security.network.client + + keychain-access-groups + + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..225aa48 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,9 @@ com.apple.security.app-sandbox + com.apple.security.network.client + + keychain-access-groups + diff --git a/pubspec.lock b/pubspec.lock index a0572a1..9a8e6ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -301,26 +301,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -522,10 +522,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" typed_data: dependency: transitive description: @@ -534,6 +534,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: @@ -599,5 +663,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index c1f04fd..9d0b928 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: intl: ^0.20.2 uuid: ^4.5.1 web_socket_channel: ^3.0.3 + url_launcher: ^6.3.1 dependency_overrides: flutter_secure_storage_linux: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0c50753..2048c45 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4fc759c..de626cc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST