diff --git a/.fvmrc b/.fvmrc index e8b4151..06bcdee 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.35.7" + "flutter": "3.41.0" } \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5bb9ba3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,135 @@ +name: Build & Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build-android: + name: Build Android APK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.0' + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Decode keystore + if: env.KEYSTORE_BASE64 != '' + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEY_PROPERTIES: ${{ secrets.KEY_PROPERTIES }} + run: | + echo "$KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks + echo "$KEY_PROPERTIES" > android/key.properties + + - name: Build APK + run: flutter build apk --release + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: build/app/outputs/flutter-apk/app-release.apk + + build-windows: + name: Build Windows Installer + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.0' + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Build Windows + run: flutter build windows --release + + - name: Create MSIX installer + run: | + dart pub global activate msix + dart pub global run msix:create --build-windows false + continue-on-error: true + + - name: Upload Windows build + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: | + build/windows/x64/runner/Release/ + build/windows/x64/runner/Release/*.msix + + build-macos: + name: Build macOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.0' + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Build macOS + run: flutter build macos --release + + - name: Create DMG + run: | + APP_PATH="build/macos/Build/Products/Release/hushnet_frontend.app" + DMG_PATH="build/macos/HushNet.dmg" + hdiutil create -volname "HushNet" -srcfolder "$APP_PATH" -ov -format UDZO "$DMG_PATH" + + - name: Upload macOS DMG + uses: actions/upload-artifact@v4 + with: + name: macos-dmg + path: build/macos/HushNet.dmg + + release: + name: Create GitHub Release + needs: [build-android, build-windows, build-macos] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release assets + run: | + mv artifacts/android-apk/app-release.apk artifacts/HushNet-android.apk + mv artifacts/macos-dmg/HushNet.dmg artifacts/HushNet-macos.dmg + cd artifacts/windows-installer && zip -r ../HushNet-windows.zip . && cd ../.. + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + artifacts/HushNet-android.apk + artifacts/HushNet-macos.dmg + artifacts/HushNet-windows.zip diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 3383c71..5a09cd9 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -23,9 +23,13 @@ jobs: # https://github.com/dart-lang/setup-dart/blob/main/README.md # - uses: dart-lang/setup-dart@v1 - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 - + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.0' + channel: stable + cache: true - name: Install dependencies - run: dart pub get + run: flutter pub get # Uncomment this step to verify the use of 'dart format' on each commit. # - name: Verify formatting diff --git a/.vscode/settings.json b/.vscode/settings.json index 0fbec86..93e9b2e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm\\versions\\3.35.7", + "dart.flutterSdkPath": ".fvm/versions/3.41.0", "C_Cpp_Runner.cCompilerPath": "gcc", "C_Cpp_Runner.cppCompilerPath": "g++", "C_Cpp_Runner.debuggerPath": "gdb", diff --git a/lib/data/node/node_connection.dart b/lib/data/node/node_connection.dart index 23cbc92..bbf8bbe 100644 --- a/lib/data/node/node_connection.dart +++ b/lib/data/node/node_connection.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:hushnet_frontend/models/node.dart'; import 'package:shared_preferences/shared_preferences.dart'; Future connectToNode( @@ -39,3 +40,22 @@ Future connectToNode( await prefs.setString('node_address', nodeAddress); } } + +Future> fetchNodes() async { + final dio = Dio(); + try { + final response = await dio.get('https://registry.hushnet.net/api/nodes'); + if (response.statusCode == 200) { + final List nodesJson = response.data['nodes'] ?? []; + final List nodes = nodesJson + .map((nodeJson) => Node.fromJson(nodeJson)) + .toList(); + return nodes; + } else { + throw Exception('Failed to load nodes'); + } + } catch (e) { + log('Error fetching nodes: $e'); + throw Exception('Failed to load nodes'); + } +} diff --git a/lib/models/node.dart b/lib/models/node.dart new file mode 100644 index 0000000..5d41b94 --- /dev/null +++ b/lib/models/node.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +class Node { + final String apiBaseUrl; + final String countryCode; + final String countryName; + final Map features; + final String host; + final String ip; + final int? lastLatencyMs; + final String lastSeenAt; + final String name; + final String protocolVersion; + final String status; + + Node({ + required this.apiBaseUrl, + required this.countryCode, + required this.countryName, + required this.features, + required this.host, + required this.ip, + required this.lastLatencyMs, + required this.lastSeenAt, + required this.name, + required this.protocolVersion, + required this.status, + }); + + factory Node.fromJson(Map json) { + return Node( + apiBaseUrl: json['api_base_url']?.toString() ?? '', + countryCode: json['country_code']?.toString() ?? '', + countryName: json['country_name']?.toString() ?? '', + features: Map.from(json['features'] ?? {}), + host: json['host']?.toString() ?? '', + ip: json['ip']?.toString() ?? '', + lastLatencyMs: json['last_latency_ms'] is int + ? json['last_latency_ms'] as int + : int.tryParse(json['last_latency_ms']?.toString() ?? ''), + lastSeenAt: json['last_seen_at']?.toString() ?? '', + name: json['name']?.toString() ?? '', + protocolVersion: json['protocol_version']?.toString() ?? '', + status: json['status']?.toString() ?? '', + ); + } +} + +List parseNodes(String responseBody) { + final decoded = jsonDecode(responseBody) as Map; + final nodesJson = decoded['nodes'] as List? ?? []; + + return nodesJson + .map((e) => Node.fromJson(e as Map)) + .toList(); +} diff --git a/lib/screens/select_node.dart b/lib/screens/select_node.dart index 300e68b..a666260 100644 --- a/lib/screens/select_node.dart +++ b/lib/screens/select_node.dart @@ -1,85 +1,473 @@ import 'package:flutter/material.dart'; +import 'package:hushnet_frontend/data/node/node_connection.dart'; +import 'package:hushnet_frontend/models/node.dart'; import 'package:hushnet_frontend/widgets/button.dart'; import 'package:hushnet_frontend/widgets/connection/bottom_sheet.dart'; -import 'package:hushnet_frontend/widgets/textfield.dart'; -import 'package:lottie/lottie.dart'; -class SelectNodeScreen extends StatelessWidget { +class SelectNodeScreen extends StatefulWidget { const SelectNodeScreen({super.key}); @override - Widget build(BuildContext context) { - final TextEditingController _nodeController = TextEditingController(); + State createState() => _SelectNodeScreenState(); +} - return Scaffold( - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Lottie.asset('assets/node.json', width: 200, height: 200), +class _SelectNodeScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + final TextEditingController _searchController = TextEditingController(); + final TextEditingController _privateNodeController = TextEditingController(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _searchController.addListener(() { + setState(() => _searchQuery = _searchController.text.toLowerCase()); + }); + } - Text( - 'Select your node', - style: Theme.of(context).textTheme.headlineMedium, + @override + void dispose() { + _tabController.dispose(); + _searchController.dispose(); + _privateNodeController.dispose(); + super.dispose(); + } + + void _connectToNode(String address) { + String nodeAddress = address.trim(); + if (nodeAddress.endsWith('/')) { + nodeAddress = nodeAddress.substring(0, nodeAddress.length - 1); + } + if (nodeAddress.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a node URL')), + ); + return; + } + showConnectionSheet(context, nodeAddress); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Select a Node', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'Choose a public node or connect to your own private node.', + style: TextStyle(color: Colors.grey[500], fontSize: 14), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(12), + ), + child: TabBar( + controller: _tabController, + indicator: BoxDecoration( + color: const Color(0xFF2563EB), + borderRadius: BorderRadius.circular(10), ), - const SizedBox(height: 8), - Text( - 'With HushNet, you can choose to connect to any node that supports our protocol. This gives you the freedom to select a node that aligns with your privacy and security preferences.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey[400]), + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, + labelColor: Colors.white, + unselectedLabelColor: Colors.grey[500], + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, ), - Text( - 'When you select a node, you are choosing the server that will handle your messages and data.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey[400]), + padding: const EdgeInsets.all(4), + tabs: const [ + Tab(text: 'Public Nodes'), + Tab(text: 'Private Node'), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildPublicNodesTab(), + _buildPrivateNodeTab(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPublicNodesTab() { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: TextFormField( + controller: _searchController, + style: const TextStyle(color: Colors.white), + cursorColor: const Color(0xFF3A8DFF), + decoration: InputDecoration( + hintText: 'Search by name, country, or host...', + prefixIcon: + const Icon(Icons.search, color: Colors.white54, size: 20), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close, + color: Colors.white54, size: 18), + onPressed: () => _searchController.clear(), + ) + : null, + filled: true, + fillColor: const Color(0xFF1E1E1E), + hintStyle: const TextStyle(color: Colors.white38, fontSize: 14), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, ), - const SizedBox(height: 16), - Text( - 'Once the URL is entered, its privacy features will be displayed, allowing you to make an informed decision.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey[400]), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: + const BorderSide(color: Color(0xFF3A8DFF), width: 1.5), ), - const SizedBox(height: 32), - HushTextField( - hint: 'Enter node URL', - icon: Icons.link, - controller: _nodeController, + ), + ), + ), + const SizedBox(height: 12), + Expanded( + child: FutureBuilder>( + future: fetchNodes(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator( + color: Color(0xFF3A8DFF), + ), + ); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, + color: Colors.grey[600], size: 48), + const SizedBox(height: 16), + Text( + 'Failed to load nodes', + style: TextStyle( + color: Colors.grey[400], fontSize: 16), + ), + const SizedBox(height: 8), + Text( + '${snapshot.error}', + style: TextStyle( + color: Colors.grey[600], fontSize: 13), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + final nodes = snapshot.data ?? []; + final filtered = nodes.where((node) { + if (_searchQuery.isEmpty) return true; + return node.name.toLowerCase().contains(_searchQuery) || + node.countryName.toLowerCase().contains(_searchQuery) || + node.host.toLowerCase().contains(_searchQuery) || + node.countryCode.toLowerCase().contains(_searchQuery); + }).toList(); + + if (filtered.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off, + color: Colors.grey[600], size: 48), + const SizedBox(height: 12), + Text( + _searchQuery.isNotEmpty + ? 'No nodes match your search' + : 'No nodes available', + style: + TextStyle(color: Colors.grey[500], fontSize: 15), + ), + ], + ), + ); + } + + return ListView.separated( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + itemCount: filtered.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) => + _buildNodeCard(filtered[index]), + ); + }, + ), + ), + ], + ); + } + + Widget _buildNodeCard(Node node) { + final isOnline = node.status.toLowerCase() == 'online'; + final latency = node.lastLatencyMs; + final countryFlag = _countryCodeToEmoji(node.countryCode); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _connectToNode(node.apiBaseUrl), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.white.withValues(alpha: 0.05), + ), + ), + child: Row( + children: [ + // Country flag + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: const Color(0xFF222222), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text( + countryFlag, + style: const TextStyle(fontSize: 22), + ), ), - const SizedBox(height: 32), - HushButton( - label: "Connect", - icon: Icons.link, - onPressed: () { - String nodeAddress = _nodeController.text.trim(); - // Remove trailing slash if present - if (nodeAddress.endsWith('/')) { - nodeAddress = nodeAddress.substring(0, nodeAddress.length - 1); - } - if (nodeAddress.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please enter a node URL')), - ); - return; - } - showConnectionSheet(context, nodeAddress); - }, + const SizedBox(width: 14), + // Node info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + node.name.isNotEmpty ? node.name : node.host, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + node.countryName.isNotEmpty + ? node.countryName + : node.host, + style: TextStyle( + color: Colors.grey[500], + fontSize: 13, + ), + ), + if (node.protocolVersion.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + 'ยท', + style: TextStyle(color: Colors.grey[600]), + ), + ), + Text( + 'v${node.protocolVersion}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ], + ], + ), + ], + ), ), - const SizedBox(height: 16), - HushButton( - label: "Go back", - icon: Icons.arrow_back, - color: Colors.grey[700]!, - onPressed: () => Navigator.pop(context), + const SizedBox(width: 12), + // Status & latency + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: isOnline + ? Colors.greenAccent + : Colors.red[400], + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + isOnline ? 'Online' : 'Offline', + style: TextStyle( + color: + isOnline ? Colors.greenAccent : Colors.red[400], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (latency != null) ...[ + const SizedBox(height: 4), + Text( + '${latency}ms', + style: TextStyle( + color: latency < 100 + ? Colors.greenAccent + : latency < 300 + ? Colors.orangeAccent + : Colors.red[400], + fontSize: 12, + ), + ), + ], + ], ), + const SizedBox(width: 8), + Icon(Icons.chevron_right, color: Colors.grey[700], size: 20), ], ), ), ), ); } + + Widget _buildPrivateNodeTab() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: const Color(0xFF2563EB).withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFF2563EB).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.shield_outlined, + color: Color(0xFF3A8DFF), + size: 20, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + 'Connect to a self-hosted or private node by entering its address below.', + style: TextStyle( + color: Colors.grey[400], + fontSize: 13, + height: 1.4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + TextFormField( + controller: _privateNodeController, + style: const TextStyle(color: Colors.white), + cursorColor: const Color(0xFF3A8DFF), + decoration: InputDecoration( + hintText: 'https://node.example.com', + prefixIcon: + const Icon(Icons.link, color: Colors.white54, size: 20), + filled: true, + fillColor: const Color(0xFF1E1E1E), + hintStyle: const TextStyle(color: Colors.white38, fontSize: 14), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: + const BorderSide(color: Color(0xFF3A8DFF), width: 1.5), + ), + ), + ), + const SizedBox(height: 20), + HushButton( + label: 'Connect', + icon: Icons.login, + fullWidth: true, + onPressed: () => _connectToNode(_privateNodeController.text), + ), + ], + ), + ); + } + + String _countryCodeToEmoji(String countryCode) { + if (countryCode.length != 2) return '๐ŸŒ'; + final int firstLetter = countryCode.codeUnitAt(0) - 0x41 + 0x1F1E6; + final int secondLetter = countryCode.codeUnitAt(1) - 0x41 + 0x1F1E6; + return String.fromCharCode(firstLetter) + String.fromCharCode(secondLetter); + } } diff --git a/pubspec.lock b/pubspec.lock index a0572a1..f33ceab 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: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" 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: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.8" typed_data: dependency: transitive description: