diff --git a/app/.gitignore b/app/.gitignore index 6922819..61e1e40 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,3 +1,5 @@ +**/.cxx/ + # Miscellaneous *.class *.log diff --git a/app/android/build/reports/problems/problems-report.html b/app/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..d98e180 --- /dev/null +++ b/app/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/app/android/gradle.properties b/app/android/gradle.properties index 598d13f..73c3168 100644 --- a/app/android/gradle.properties +++ b/app/android/gradle.properties @@ -1,3 +1,6 @@ org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties index e1ca574..37f853b 100644 --- a/app/android/gradle/wrapper/gradle-wrapper.properties +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip diff --git a/app/android/settings.gradle b/app/android/settings.gradle index a6efc8a..1b7f201 100644 --- a/app/android/settings.gradle +++ b/app/android/settings.gradle @@ -19,8 +19,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "com.android.application" version '8.10.0-beta01' apply false + id "org.jetbrains.kotlin.android" version "2.1.20" apply false } include ":app" \ No newline at end of file diff --git a/app/l10n.yaml b/app/l10n.yaml new file mode 100644 index 0000000..fcf468c --- /dev/null +++ b/app/l10n.yaml @@ -0,0 +1,7 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-dir: lib/l10n +output-localization-file: app_localizations.dart + +synthetic-package: false +nullable-getter: false \ No newline at end of file diff --git a/app/lib/authed_client.dart b/app/lib/authed_client.dart deleted file mode 100644 index 32b35e7..0000000 --- a/app/lib/authed_client.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:io'; -import 'package:http/http.dart' as http; -import 'package:path_provider/path_provider.dart'; - -class AuthedClientInner extends http.BaseClient { - final String _secret; - final http.Client _inner; - - AuthedClientInner(this._secret, this._inner); - - @override - Future send(http.BaseRequest request) { - request.headers['X-ZT1-Auth'] = _secret; - return _inner.send(request); - } - - // @override - // Future post(Uri url, - // {Map? headers, Object? body, Encoding? encoding}) { - // Map hd = headers ?? {}; - // hd['content-type'] = 'application/json'; - // return super.post(url, headers: hd, body: body, encoding: encoding); - // } -} - -class AuthedClient { - late Future client; - Future loadSecret() async { - final path = (await getApplicationDocumentsDirectory()).path; - final file = File('$path/run/authtoken'); - final token = await file.readAsString(); - - return AuthedClientInner(token.trim(), http.Client()); - } - - AuthedClient() { - client = loadSecret(); - } -} diff --git a/app/lib/components/network_page.dart b/app/lib/components/network_page.dart new file mode 100644 index 0000000..5e8857a --- /dev/null +++ b/app/lib/components/network_page.dart @@ -0,0 +1,357 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:quickalert/quickalert.dart'; +import '../services/zerotier_service.dart'; +import '../l10n/app_localizations.dart'; + +class NetworkPage extends StatefulWidget { + final ZerotierService zerotierService; + + const NetworkPage({ + super.key, + required this.zerotierService, + }); + + @override + State createState() => _NetworkPageState(); +} + +class _NetworkPageState extends State { + final _idInputController = TextEditingController(); + bool _isLoading = false; + List _networks = []; + String? _error; + + @override + void initState() { + super.initState(); + _idInputController.addListener(_onTextChanged); + _fetchNetworkList(); + } + + void _onTextChanged() { + if (!mounted) return; + setState(() {}); + } + + void _setLoading(bool loading) { + if (!mounted) return; + setState(() { + _isLoading = loading; + if (loading && _error != null) { + _error = null; + } + }); + } + + Future _fetchNetworkList() async { + _setLoading(true); + try { + final networks = await widget.zerotierService.loadNetwork(); + if (mounted) { + final l10n = AppLocalizations.of(context)!; + setState(() { + if (networks == null) { + // ZeroTier服务不可用 + _error = l10n.serviceNotRunning; + _networks = []; + } else { + _networks = List.from(networks); + _error = null; + } + }); + } + } catch (e) { + if (mounted) { + // 存储原始错误信息 + final rawError = e.toString(); + setState(() { + _error = rawError; // 存储原始错误 + _networks = []; + }); + + // 在界面上显示本地化错误信息 + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.loadNetworksErrorText(rawError)), + backgroundColor: Colors.red, + ), + ); + } + } finally { + _setLoading(false); + } + } + + Future _performJoinNetwork(String networkId) async { + if (_isLoading) return; + _setLoading(true); + final l10n = AppLocalizations.of(context)!; + + try { + final result = await widget.zerotierService.joinNetwork(networkId); + if (mounted) { + if (!widget.zerotierService.runningStatus) { + // ZeroTier服务未运行 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.serviceNotRunning), + backgroundColor: Colors.red, + ), + ); + } else if (result) { + QuickAlert.show( + context: context, + type: QuickAlertType.success, + text: l10n.joinNetworkSuccessText(networkId), + ); + _idInputController.clear(); + } else { + // 其他失败 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.joinNetworkErrorText("Unknown error")), + backgroundColor: Colors.red, + ), + ); + } + await _fetchNetworkList(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.joinNetworkErrorText(e.toString())), backgroundColor: Colors.red), + ); + } + } finally { + _setLoading(false); + } + } + + Future _performLeaveNetwork(String networkId) async { + if (_isLoading) return; + _setLoading(true); + final l10n = AppLocalizations.of(context)!; + + try { + final result = await widget.zerotierService.leaveNetwork(networkId); + if (mounted) { + if (!widget.zerotierService.runningStatus) { + // ZeroTier服务未运行 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.serviceNotRunning), + backgroundColor: Colors.red, + ), + ); + } else if (result) { + QuickAlert.show( + context: context, + type: QuickAlertType.success, + text: l10n.leaveNetworkSuccessText(networkId), + ); + } else { + // 其他失败 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.leaveNetworkErrorText("Unknown error")), + backgroundColor: Colors.red, + ), + ); + } + await _fetchNetworkList(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.leaveNetworkErrorText(e.toString())), backgroundColor: Colors.red), + ); + } + } finally { + _setLoading(false); + } + } + + void _showLeaveConfirmation(String networkId) { + final l10n = AppLocalizations.of(context)!; + QuickAlert.show( + context: context, + type: QuickAlertType.confirm, + title: l10n.leaveNetworkConfirmationTitle, + text: l10n.leaveNetworkConfirmationText(networkId), + confirmBtnText: l10n.confirmButton, + cancelBtnText: l10n.cancelButton, + confirmBtnColor: Colors.red, + onConfirmBtnTap: () { + Navigator.pop(context); + _performLeaveNetwork(networkId); + }, + onCancelBtnTap: () => Navigator.pop(context), + ); + } + + @override + void dispose() { + _idInputController.removeListener(_onTextChanged); + _idInputController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + Expanded( + child: TextField( + controller: _idInputController, + textAlign: TextAlign.center, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9a-fA-F]')) + ], + enabled: !_isLoading, + maxLength: 16, + decoration: InputDecoration( + labelText: l10n.networkIdLabel, + hintText: l10n.networkIdHint, + prefixIcon: const Icon(Icons.lan_outlined), + suffixIcon: _idInputController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: !_isLoading ? () => _idInputController.clear() : null, + tooltip: l10n.clearInputTooltip, + ) + : null, + counterText: "", + border: const OutlineInputBorder() + ) + ) + ), + const SizedBox(width: 12), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + onPressed: _isLoading || _idInputController.text.isEmpty + ? null + : () { + final networkId = _idInputController.text; + if (networkId.length != 16) { + QuickAlert.show( + context: context, + type: QuickAlertType.warning, + title: l10n.invalidNetworkIdTitle, + text: l10n.invalidNetworkIdText, + ); + return; + } + _performJoinNetwork(networkId); + }, + label: Text(l10n.joinButton), + icon: const Icon(Icons.add_link)), + ]), + const SizedBox(height: 24), + const Divider(), + ListTile( + title: Text(l10n.networksTitle, style: Theme.of(context).textTheme.titleMedium), + trailing: IconButton( + icon: const Icon(Icons.refresh), + tooltip: l10n.refreshNetworksTooltip, + onPressed: _isLoading ? null : _fetchNetworkList + ) + ), + const SizedBox(height: 8), + Expanded( + child: _isLoading && _networks.isEmpty + ? const Center(child: CircularProgressIndicator.adaptive()) + : _error != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.loadNetworksErrorText(_error!), + style: TextStyle(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: Text(l10n.refreshButtonLabel ?? 'Retry'), + onPressed: _isLoading ? null : _fetchNetworkList, + ) + ], + ), + ) + ) + : RefreshIndicator( + onRefresh: _fetchNetworkList, + child: _networks.isEmpty + ? LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Container( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + l10n.noNetworksJoined, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).disabledColor + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ) + : ListView.builder( + itemCount: _networks.length, + itemBuilder: (BuildContext ctx, int i) { + final networkId = _networks[i]; + return Card( + elevation: 1.5, + margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 0), + child: ListTile( + leading: CircleAvatar( + child: Icon(Icons.vpn_key, size: 18, color: Theme.of(context).colorScheme.secondary), + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + ), + title: Text(networkId, style: const TextStyle(fontFamily: 'monospace', letterSpacing: 0.8)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _isLoading ? null : () { + Clipboard.setData(ClipboardData(text: networkId)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.copiedToClipboard(networkId))) + ); + }, + tooltip: l10n.copyTooltip, + icon: const Icon(Icons.copy, size: 20) + ), + IconButton( + onPressed: _isLoading ? null : () => _showLeaveConfirmation(networkId), + tooltip: l10n.leaveTooltip, + icon: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error, size: 20) + ), + ]) + ) + ); + } + ), + ) + ), + ], + )); + } +} \ No newline at end of file diff --git a/app/lib/components/peers_page.dart b/app/lib/components/peers_page.dart new file mode 100644 index 0000000..3739b2a --- /dev/null +++ b/app/lib/components/peers_page.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import '../services/zerotier_service.dart'; +import '../l10n/app_localizations.dart'; // Import localizations +import '../models/peer_info.dart'; // Import the model + +// class PeersPage extends StatelessWidget { // Convert to StatefulWidget +class PeersPage extends StatefulWidget { + final ZerotierService zerotierService; + // Remove callback parameter + // final Future Function()? onRefreshPeers; + + const PeersPage({ + super.key, + required this.zerotierService, + // Remove callback parameter from constructor + // this.onRefreshPeers, + }); + + @override + // State createState() => _PeersPageState(); // Add createState + State createState() => _PeersPageState(); +} + +// Add State class +class _PeersPageState extends State { + bool _isLoading = false; + // Use the PeerInfo model for the list + List _peers = []; + String? _error; + + @override + void initState() { + super.initState(); + _fetchPeers(); // Call fetch method on init + } + + // Helper to manage loading state + void _setLoading(bool loading) { + if (!mounted) return; + setState(() { + _isLoading = loading; + if (loading) { + _error = null; // Clear error when starting load + } + }); + } + + // Placeholder method to fetch peers + Future _fetchPeers() async { + _setLoading(true); + + try { + // 调用服务方法并获取返回值 + final peers = await widget.zerotierService.loadPeers(); + if (mounted) { + final l10n = AppLocalizations.of(context)!; + setState(() { + if (peers == null) { + // ZeroTier服务不可用 + _error = l10n.serviceNotRunning; + _peers = []; + } else { + _peers = List.from(peers); + _error = null; + } + }); + } + } catch (e) { + if (mounted) { + // 存储原始错误信息,不使用 l10n + final rawError = e.toString(); + setState(() { + // 在 setState 内部构建错误显示文本 + _error = rawError; // 存储原始错误,显示时再格式化 + _peers = []; + }); + + // 在确认 mounted 后使用 l10n 和 context + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.loadPeersErrorText(rawError)), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + _setLoading(false); + } + } + } + + // Helper to build list item display + Widget _buildPeerTile(PeerInfo peer, AppLocalizations l10n) { + IconData roleIcon = peer.isPlanet ? Icons.cloud_outlined : Icons.computer_outlined; + Color roleColor = peer.isPlanet ? Colors.blueGrey : Theme.of(context).colorScheme.secondary; + + // 设置tunneled状态显示 + final tunnelIcon = peer.tunneled ? Icons.settings_ethernet : Icons.wifi; + final tunnelColor = peer.tunneled ? Colors.orange.shade700 : Colors.green.shade600; + final tunnelText = peer.tunneled ? l10n.peerTunneled : l10n.peerDirect; + + return Card( + elevation: 1.5, + margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: roleColor.withOpacity(0.1), + child: Icon(roleIcon, size: 20, color: roleColor), + ), + title: Row( + children: [ + Expanded( + child: Text( + peer.address, + style: const TextStyle(fontFamily: 'monospace', fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + // 对于非PLANET节点显示版本 + if (!peer.isPlanet && peer.version != null) ...[ + const SizedBox(width: 8), + Text( + peer.version!, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + fontWeight: FontWeight.normal + ), + ), + ], + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Row( + children: [ + Chip( + avatar: Icon(roleIcon, size: 14, color: roleColor), + label: Text(peer.role, style: TextStyle(fontSize: 12, color: roleColor)), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), + visualDensity: VisualDensity.compact, + backgroundColor: roleColor.withOpacity(0.1), + side: BorderSide.none, + ), + const SizedBox(width: 8), + // 添加tunneled状态显示 + Chip( + avatar: Icon(tunnelIcon, size: 14, color: tunnelColor), + label: Text(tunnelText, style: TextStyle(fontSize: 12, color: tunnelColor)), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), + visualDensity: VisualDensity.compact, + backgroundColor: tunnelColor.withOpacity(0.1), + side: BorderSide.none, + ), + const SizedBox(width: 8), + if (peer.latency != null) ...[ + Icon(Icons.timer_outlined, size: 14, color: Colors.orange.shade700), + const SizedBox(width: 4), + Text('${peer.latency} ms', style: TextStyle(fontSize: 12, color: Colors.orange.shade700)), + ], + ], + ), + if (peer.preferredPath != null) ...[ + const SizedBox(height: 4), + Text( + peer.preferredPath!, + style: TextStyle(fontSize: 11, color: Theme.of(context).disabledColor, fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + ] + ], + ), + dense: true, + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + Widget bodyContent; + + if (_isLoading && _peers.isEmpty) { + bodyContent = const Center(child: CircularProgressIndicator.adaptive()); + } else if (_error != null) { + bodyContent = Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.loadPeersErrorText(_error!), + style: TextStyle(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: Text(l10n.refreshButtonLabel), + onPressed: _isLoading ? null : _fetchPeers, + ) + ], + ), + ) + ); + } else if (!_isLoading && _peers.isEmpty) { + // Show "No peers found" centered, allowing pull-to-refresh + bodyContent = LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Container( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + l10n.noPeersFound, // Uses existing l10n key + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).disabledColor + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ); + } else { + // Display the list using ListView.builder + bodyContent = ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), // Ensure scrollable even when few items + itemCount: _peers.length, + itemBuilder: (context, index) { + return _buildPeerTile(_peers[index], l10n); + }, + ); + } + + // Wrap the main content with RefreshIndicator + return RefreshIndicator( + onRefresh: _fetchPeers, + child: bodyContent, + ); + } +} \ No newline at end of file diff --git a/app/lib/components/status_page.dart b/app/lib/components/status_page.dart new file mode 100644 index 0000000..494d55a --- /dev/null +++ b/app/lib/components/status_page.dart @@ -0,0 +1,390 @@ +import 'package:flutter/material.dart'; +import '../services/zerotier_service.dart'; +import '../l10n/app_localizations.dart'; + +class StatusPage extends StatefulWidget { + final ZerotierService zerotierService; + + const StatusPage({ + super.key, + required this.zerotierService, + }); + + @override + State createState() => _StatusPageState(); +} + +class _StatusPageState extends State { + bool _isLoading = false; + ZerotierStatus? _statusInfo; + bool _isModuleInstalled = true; + + @override + void initState() { + super.initState(); + + // 获取缓存的状态信息,如果有则直接使用 + final cachedStatus = widget.zerotierService.statusInfo; + if (cachedStatus != null) { + _statusInfo = cachedStatus; + _isModuleInstalled = true; + } + + // 然后异步更新状态 + _fetchStatus(); + } + + Future _fetchStatus() async { + if (!mounted) return; + + // 检查是否有缓存的状态 + final cachedStatus = widget.zerotierService.statusInfo; + final bool hasCache = cachedStatus != null; + + // 如果没有缓存或者当前状态为空,才显示加载中 + if (!hasCache || _statusInfo == null) { + setState(() => _isLoading = true); + } + + try { + final status = await widget.zerotierService.loadStatus(); + if (mounted) { + setState(() { + _statusInfo = status; + _isModuleInstalled = true; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load status: $e')), + ); + setState(() => _isLoading = false); + } + } + } + + Future _performServiceAction(String command) async { + if (!mounted || _isLoading) return; + setState(() => _isLoading = true); + + final l10n = AppLocalizations.of(context)!; + String actionVerb = command; + if (command == 'start') actionVerb = 'Starting'; + if (command == 'stop') actionVerb = 'Stopping'; + if (command == 'restart') actionVerb = 'Restarting'; + + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$actionVerb ZeroTier...'), duration: const Duration(seconds: 1)), + ); + + final success = await widget.zerotierService.zerotierCommand(command); + + if (!success && mounted) { + setState(() { + _isModuleInstalled = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.moduleNotRunning), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$command failed: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + await _fetchStatus(); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cachedStatus = widget.zerotierService.statusInfo; + final bool runningStatus = (_statusInfo != null && _statusInfo!.online) || + (cachedStatus != null && cachedStatus.online && _statusInfo == null); + final statusColor = runningStatus ? Colors.green.shade400 : Colors.red.shade400; + final l10n = AppLocalizations.of(context)!; + + return RefreshIndicator( + onRefresh: _fetchStatus, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 16.0), + child: Column( + children: [ + _buildStatusIcon(runningStatus), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildStatusText(context, runningStatus, theme, l10n), + IconButton( + icon: Icon(Icons.refresh, color: theme.colorScheme.secondary), + onPressed: _isLoading ? null : _fetchStatus, + tooltip: l10n.refreshStatusTooltip, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + + _ActionButtons( + l10n: l10n, + startFunction: () => _performServiceAction('start'), + restartFunction: () => _performServiceAction('restart'), + stopFunction: () => _performServiceAction('stop'), + isServiceRunning: runningStatus, + isModuleInstalled: _isModuleInstalled, + ), + const SizedBox(height: 20), + + if (_isLoading) + const Center(child: Padding( + padding: EdgeInsets.symmetric(vertical: 32.0), + child: CircularProgressIndicator(), + )) + else if (_statusInfo != null && runningStatus) + _buildStatusInfo(context, _statusInfo!) + else if (cachedStatus != null && cachedStatus.online) + _buildStatusInfo(context, cachedStatus) + else + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32.0, horizontal: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildErrorText(context, theme, l10n), + ], + ), + ) + ) + ], + ), + ), + ); + } + + Widget _buildStatusIcon(bool runningStatus) { + IconData iconData; + Color iconColor; + + if (!_isModuleInstalled) { + iconData = Icons.error; + iconColor = Colors.orange.shade700; + } else if (runningStatus) { + iconData = Icons.play_circle_fill; + iconColor = Colors.green.shade400; + } else { + iconData = Icons.stop_circle; + iconColor = Colors.red.shade400; + } + + return Icon( + iconData, + color: iconColor, + size: 64, + ); + } + + Widget _buildStatusText(BuildContext context, bool runningStatus, ThemeData theme, AppLocalizations l10n) { + String statusText; + Color textColor; + + if (!_isModuleInstalled) { + statusText = l10n.moduleNotRunning; + textColor = Colors.orange.shade700; + } else if (runningStatus) { + statusText = l10n.statusRunning; + textColor = Colors.green.shade400; + } else { + statusText = l10n.statusStopped; + textColor = Colors.red.shade400; + } + + if (_isLoading) { + textColor = textColor.withOpacity(0.5); + } + + return Text( + statusText, + style: theme.textTheme.headlineSmall?.copyWith( + color: textColor, + fontWeight: FontWeight.bold + ), + ); + } + + Widget _buildErrorText(BuildContext context, ThemeData theme, AppLocalizations l10n) { + String errorText; + Color textColor; + + if (!_isModuleInstalled) { + errorText = l10n.moduleNotRunning; + textColor = Colors.orange.shade700; + } else { + errorText = l10n.statusStopped; + textColor = theme.colorScheme.error; + } + + return Text( + errorText, + style: theme.textTheme.bodyMedium?.copyWith( + color: textColor + ), + textAlign: TextAlign.center, + ); + } + + Widget _buildStatusInfo(BuildContext context, ZerotierStatus status) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.statusDetailsTitle, + style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold) + ), + const Divider(height: 20, thickness: 1), + _buildInfoTile(context, Icons.lan_outlined, l10n.nodeAddressLabel, status.address), + _buildInfoTile(context, Icons.info_outline, l10n.softwareVersionLabel, status.version), + _buildInfoTile(context, status.online ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, l10n.onlineStatusLabel, status.online ? l10n.onlineStatusOnline : l10n.onlineStatusOffline), + _buildInfoTile(context, Icons.settings_ethernet, l10n.primaryPortLabel, status.primaryPort.toString()), + const SizedBox(height: 10), + ListTile( + leading: Icon(Icons.list_alt, color: theme.colorScheme.secondary), + title: Text(l10n.listeningAddressesLabel, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)), + ), + Container( + height: 100, + margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.dividerColor), + ), + child: status.listeningOn.isEmpty + ? Center(child: Text(l10n.noListeningAddresses, style: theme.textTheme.bodySmall)) + : ListView.builder( + shrinkWrap: true, + itemCount: status.listeningOn.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Text( + status.listeningOn[index], + style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildInfoTile(BuildContext context, IconData icon, String label, String value) { + final theme = Theme.of(context); + return ListTile( + leading: Icon(icon, color: theme.colorScheme.secondary), + title: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)), + subtitle: Text(value, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace')), + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), + ); + } +} + +class _ActionButtons extends StatelessWidget { + final AppLocalizations l10n; + final Function()? startFunction; + final Function()? restartFunction; + final Function()? stopFunction; + final bool isServiceRunning; + final bool isModuleInstalled; + + const _ActionButtons({ + required this.l10n, + required this.startFunction, + required this.restartFunction, + required this.stopFunction, + required this.isServiceRunning, + required this.isModuleInstalled, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + Center( + child: SizedBox( + width: 180, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)), + onPressed: isServiceRunning || !isModuleInstalled ? null : startFunction, + label: Text(l10n.startButton), + icon: const Icon(Icons.play_arrow), + ), + ), + ), + const SizedBox(height: 16), + Center( + child: SizedBox( + width: 180, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)), + onPressed: isServiceRunning && isModuleInstalled ? restartFunction : null, + label: Text(l10n.restartButton), + icon: const Icon(Icons.restart_alt), + ), + ), + ), + const SizedBox(height: 16), + Center( + child: SizedBox( + width: 180, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + side: BorderSide(color: theme.colorScheme.error), + padding: const EdgeInsets.symmetric(vertical: 12) + ), + onPressed: isServiceRunning && isModuleInstalled ? stopFunction : null, + label: Text(l10n.stopButton), + icon: const Icon(Icons.stop), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb new file mode 100644 index 0000000..16ddc50 --- /dev/null +++ b/app/lib/l10n/app_en.arb @@ -0,0 +1,125 @@ +{ + "@@locale": "en", + "statusRunning": "Running", + "statusStopped": "Stopped", + "refreshStatusTooltip": "Refresh Status", + "startButton": "Start", + "restartButton": "Restart", + "stopButton": "Stop", + "statusDetailsTitle": "ZeroTier Details", + "nodeAddressLabel": "Node Address", + "softwareVersionLabel": "Software Version", + "onlineStatusLabel": "Online Status", + "onlineStatusOnline": "Online", + "onlineStatusOffline": "Offline", + "primaryPortLabel": "Primary Port", + "listeningAddressesLabel": "Listening Addresses:", + "noListeningAddresses": "None", + + "leaveNetworkConfirmationTitle": "Leave Network?", + "leaveNetworkConfirmationText": "Are you sure you want to leave network {networkId}?", + "@leaveNetworkConfirmationText": { + "placeholders": { + "networkId": { + "type": "String", + "example": "1234567890abcdef" + } + } + }, + "confirmButton": "Confirm", + "cancelButton": "Cancel", + "networkIdLabel": "Network ID", + "networkIdHint": "Enter 16-character Network ID", + "invalidNetworkIdTitle": "Invalid Network ID", + "invalidNetworkIdText": "Please enter a 16-character network ID.", + "joinButton": "Join", + "networksTitle": "Networks", + "refreshNetworksTooltip": "Refresh Network List", + "noNetworksJoined": "No networks joined", + "copiedToClipboard": "Copied {networkId} to clipboard!", + "@copiedToClipboard": { + "placeholders": { + "networkId": { + "type": "String", + "example": "1234567890abcdef" + } + } + }, + "copyTooltip": "Copy", + "leaveTooltip": "Leave", + "clearInputTooltip": "Clear input", + "peersFeatureComingSoon": "Peers feature coming soon", + "noPeersFound": "No peers found", + + "navBarLabelStatus": "Status", + "navBarLabelNetworks": "Networks", + "navBarLabelPeers": "Peers", + "appBarTitleStatus": "Status", + "appBarTitleNetworks": "Networks", + "appBarTitlePeers": "Peers", + + "joinNetworkSuccessText": "Successfully joined network {networkId}!", + "@joinNetworkSuccessText": { + "placeholders": { + "networkId": { + "type": "String", + "example": "1234567890abcdef" + } + } + }, + "joinNetworkErrorText": "Failed to join network: {error}", + "@joinNetworkErrorText": { + "placeholders": { + "error": { + "type": "String", + "example": "Connection timed out" + } + } + }, + "leaveNetworkSuccessText": "Successfully left network {networkId}.", + "@leaveNetworkSuccessText": { + "placeholders": { + "networkId": { + "type": "String", + "example": "1234567890abcdef" + } + } + }, + "leaveNetworkErrorText": "Failed to leave network: {error}", + "@leaveNetworkErrorText": { + "placeholders": { + "error": { + "type": "String", + "example": "Permission denied" + } + } + }, + "refreshButtonLabel": "Retry", + + "loadPeersErrorText": "Failed to load peers: {error}", + "@loadPeersErrorText": { + "placeholders": { + "error": { + "type": "String", + "example": "Network error" + } + } + }, + + "loadNetworksErrorText": "Failed to load networks: {error}", + "@loadNetworksErrorText": { + "placeholders": { + "error": { + "type": "String", + "example": "Network error" + } + } + }, + + "serviceNotRunning": "ZeroTier service is not running", + + "moduleNotRunning": "ZeroTier Magisk module is not running. Please check if you have installed the ZeroTier Magisk module.", + + "peerTunneled": "Relayed", + "peerDirect": "Direct" +} \ No newline at end of file diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..2aae36d --- /dev/null +++ b/app/lib/l10n/app_localizations.dart @@ -0,0 +1,429 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('zh') + ]; + + /// No description provided for @statusRunning. + /// + /// In en, this message translates to: + /// **'Running'** + String get statusRunning; + + /// No description provided for @statusStopped. + /// + /// In en, this message translates to: + /// **'Stopped'** + String get statusStopped; + + /// No description provided for @refreshStatusTooltip. + /// + /// In en, this message translates to: + /// **'Refresh Status'** + String get refreshStatusTooltip; + + /// No description provided for @startButton. + /// + /// In en, this message translates to: + /// **'Start'** + String get startButton; + + /// No description provided for @restartButton. + /// + /// In en, this message translates to: + /// **'Restart'** + String get restartButton; + + /// No description provided for @stopButton. + /// + /// In en, this message translates to: + /// **'Stop'** + String get stopButton; + + /// No description provided for @statusDetailsTitle. + /// + /// In en, this message translates to: + /// **'ZeroTier Details'** + String get statusDetailsTitle; + + /// No description provided for @nodeAddressLabel. + /// + /// In en, this message translates to: + /// **'Node Address'** + String get nodeAddressLabel; + + /// No description provided for @softwareVersionLabel. + /// + /// In en, this message translates to: + /// **'Software Version'** + String get softwareVersionLabel; + + /// No description provided for @onlineStatusLabel. + /// + /// In en, this message translates to: + /// **'Online Status'** + String get onlineStatusLabel; + + /// No description provided for @onlineStatusOnline. + /// + /// In en, this message translates to: + /// **'Online'** + String get onlineStatusOnline; + + /// No description provided for @onlineStatusOffline. + /// + /// In en, this message translates to: + /// **'Offline'** + String get onlineStatusOffline; + + /// No description provided for @primaryPortLabel. + /// + /// In en, this message translates to: + /// **'Primary Port'** + String get primaryPortLabel; + + /// No description provided for @listeningAddressesLabel. + /// + /// In en, this message translates to: + /// **'Listening Addresses:'** + String get listeningAddressesLabel; + + /// No description provided for @noListeningAddresses. + /// + /// In en, this message translates to: + /// **'None'** + String get noListeningAddresses; + + /// No description provided for @leaveNetworkConfirmationTitle. + /// + /// In en, this message translates to: + /// **'Leave Network?'** + String get leaveNetworkConfirmationTitle; + + /// No description provided for @leaveNetworkConfirmationText. + /// + /// In en, this message translates to: + /// **'Are you sure you want to leave network {networkId}?'** + String leaveNetworkConfirmationText(String networkId); + + /// No description provided for @confirmButton. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirmButton; + + /// No description provided for @cancelButton. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancelButton; + + /// No description provided for @networkIdLabel. + /// + /// In en, this message translates to: + /// **'Network ID'** + String get networkIdLabel; + + /// No description provided for @networkIdHint. + /// + /// In en, this message translates to: + /// **'Enter 16-character Network ID'** + String get networkIdHint; + + /// No description provided for @invalidNetworkIdTitle. + /// + /// In en, this message translates to: + /// **'Invalid Network ID'** + String get invalidNetworkIdTitle; + + /// No description provided for @invalidNetworkIdText. + /// + /// In en, this message translates to: + /// **'Please enter a 16-character network ID.'** + String get invalidNetworkIdText; + + /// No description provided for @joinButton. + /// + /// In en, this message translates to: + /// **'Join'** + String get joinButton; + + /// No description provided for @networksTitle. + /// + /// In en, this message translates to: + /// **'Networks'** + String get networksTitle; + + /// No description provided for @refreshNetworksTooltip. + /// + /// In en, this message translates to: + /// **'Refresh Network List'** + String get refreshNetworksTooltip; + + /// No description provided for @noNetworksJoined. + /// + /// In en, this message translates to: + /// **'No networks joined'** + String get noNetworksJoined; + + /// No description provided for @copiedToClipboard. + /// + /// In en, this message translates to: + /// **'Copied {networkId} to clipboard!'** + String copiedToClipboard(String networkId); + + /// No description provided for @copyTooltip. + /// + /// In en, this message translates to: + /// **'Copy'** + String get copyTooltip; + + /// No description provided for @leaveTooltip. + /// + /// In en, this message translates to: + /// **'Leave'** + String get leaveTooltip; + + /// No description provided for @clearInputTooltip. + /// + /// In en, this message translates to: + /// **'Clear input'** + String get clearInputTooltip; + + /// No description provided for @peersFeatureComingSoon. + /// + /// In en, this message translates to: + /// **'Peers feature coming soon'** + String get peersFeatureComingSoon; + + /// No description provided for @noPeersFound. + /// + /// In en, this message translates to: + /// **'No peers found'** + String get noPeersFound; + + /// No description provided for @navBarLabelStatus. + /// + /// In en, this message translates to: + /// **'Status'** + String get navBarLabelStatus; + + /// No description provided for @navBarLabelNetworks. + /// + /// In en, this message translates to: + /// **'Networks'** + String get navBarLabelNetworks; + + /// No description provided for @navBarLabelPeers. + /// + /// In en, this message translates to: + /// **'Peers'** + String get navBarLabelPeers; + + /// No description provided for @appBarTitleStatus. + /// + /// In en, this message translates to: + /// **'Status'** + String get appBarTitleStatus; + + /// No description provided for @appBarTitleNetworks. + /// + /// In en, this message translates to: + /// **'Networks'** + String get appBarTitleNetworks; + + /// No description provided for @appBarTitlePeers. + /// + /// In en, this message translates to: + /// **'Peers'** + String get appBarTitlePeers; + + /// No description provided for @joinNetworkSuccessText. + /// + /// In en, this message translates to: + /// **'Successfully joined network {networkId}!'** + String joinNetworkSuccessText(String networkId); + + /// No description provided for @joinNetworkErrorText. + /// + /// In en, this message translates to: + /// **'Failed to join network: {error}'** + String joinNetworkErrorText(String error); + + /// No description provided for @leaveNetworkSuccessText. + /// + /// In en, this message translates to: + /// **'Successfully left network {networkId}.'** + String leaveNetworkSuccessText(String networkId); + + /// No description provided for @leaveNetworkErrorText. + /// + /// In en, this message translates to: + /// **'Failed to leave network: {error}'** + String leaveNetworkErrorText(String error); + + /// No description provided for @refreshButtonLabel. + /// + /// In en, this message translates to: + /// **'Retry'** + String get refreshButtonLabel; + + /// No description provided for @loadPeersErrorText. + /// + /// In en, this message translates to: + /// **'Failed to load peers: {error}'** + String loadPeersErrorText(String error); + + /// No description provided for @loadNetworksErrorText. + /// + /// In en, this message translates to: + /// **'Failed to load networks: {error}'** + String loadNetworksErrorText(String error); + + /// No description provided for @serviceNotRunning. + /// + /// In en, this message translates to: + /// **'ZeroTier service is not running'** + String get serviceNotRunning; + + /// No description provided for @moduleNotRunning. + /// + /// In en, this message translates to: + /// **'ZeroTier Magisk module is not running. Please check if you have installed the ZeroTier Magisk module.'** + String get moduleNotRunning; + + /// No description provided for @peerTunneled. + /// + /// In en, this message translates to: + /// **'Relayed'** + String get peerTunneled; + + /// No description provided for @peerDirect. + /// + /// In en, this message translates to: + /// **'Direct'** + String get peerDirect; +} + +class _AppLocalizationsDelegate extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => ['en', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + + + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': return AppLocalizationsEn(); + case 'zh': return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.' + ); +} diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..d6d0cf3 --- /dev/null +++ b/app/lib/l10n/app_localizations_en.dart @@ -0,0 +1,176 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get statusRunning => 'Running'; + + @override + String get statusStopped => 'Stopped'; + + @override + String get refreshStatusTooltip => 'Refresh Status'; + + @override + String get startButton => 'Start'; + + @override + String get restartButton => 'Restart'; + + @override + String get stopButton => 'Stop'; + + @override + String get statusDetailsTitle => 'ZeroTier Details'; + + @override + String get nodeAddressLabel => 'Node Address'; + + @override + String get softwareVersionLabel => 'Software Version'; + + @override + String get onlineStatusLabel => 'Online Status'; + + @override + String get onlineStatusOnline => 'Online'; + + @override + String get onlineStatusOffline => 'Offline'; + + @override + String get primaryPortLabel => 'Primary Port'; + + @override + String get listeningAddressesLabel => 'Listening Addresses:'; + + @override + String get noListeningAddresses => 'None'; + + @override + String get leaveNetworkConfirmationTitle => 'Leave Network?'; + + @override + String leaveNetworkConfirmationText(String networkId) { + return 'Are you sure you want to leave network $networkId?'; + } + + @override + String get confirmButton => 'Confirm'; + + @override + String get cancelButton => 'Cancel'; + + @override + String get networkIdLabel => 'Network ID'; + + @override + String get networkIdHint => 'Enter 16-character Network ID'; + + @override + String get invalidNetworkIdTitle => 'Invalid Network ID'; + + @override + String get invalidNetworkIdText => 'Please enter a 16-character network ID.'; + + @override + String get joinButton => 'Join'; + + @override + String get networksTitle => 'Networks'; + + @override + String get refreshNetworksTooltip => 'Refresh Network List'; + + @override + String get noNetworksJoined => 'No networks joined'; + + @override + String copiedToClipboard(String networkId) { + return 'Copied $networkId to clipboard!'; + } + + @override + String get copyTooltip => 'Copy'; + + @override + String get leaveTooltip => 'Leave'; + + @override + String get clearInputTooltip => 'Clear input'; + + @override + String get peersFeatureComingSoon => 'Peers feature coming soon'; + + @override + String get noPeersFound => 'No peers found'; + + @override + String get navBarLabelStatus => 'Status'; + + @override + String get navBarLabelNetworks => 'Networks'; + + @override + String get navBarLabelPeers => 'Peers'; + + @override + String get appBarTitleStatus => 'Status'; + + @override + String get appBarTitleNetworks => 'Networks'; + + @override + String get appBarTitlePeers => 'Peers'; + + @override + String joinNetworkSuccessText(String networkId) { + return 'Successfully joined network $networkId!'; + } + + @override + String joinNetworkErrorText(String error) { + return 'Failed to join network: $error'; + } + + @override + String leaveNetworkSuccessText(String networkId) { + return 'Successfully left network $networkId.'; + } + + @override + String leaveNetworkErrorText(String error) { + return 'Failed to leave network: $error'; + } + + @override + String get refreshButtonLabel => 'Retry'; + + @override + String loadPeersErrorText(String error) { + return 'Failed to load peers: $error'; + } + + @override + String loadNetworksErrorText(String error) { + return 'Failed to load networks: $error'; + } + + @override + String get serviceNotRunning => 'ZeroTier service is not running'; + + @override + String get moduleNotRunning => 'ZeroTier Magisk module is not running. Please check if you have installed the ZeroTier Magisk module.'; + + @override + String get peerTunneled => 'Relayed'; + + @override + String get peerDirect => 'Direct'; +} diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart new file mode 100644 index 0000000..09cc147 --- /dev/null +++ b/app/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,176 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get statusRunning => '运行中'; + + @override + String get statusStopped => '已停止'; + + @override + String get refreshStatusTooltip => '刷新状态'; + + @override + String get startButton => '启动'; + + @override + String get restartButton => '重启'; + + @override + String get stopButton => '停止'; + + @override + String get statusDetailsTitle => 'ZeroTier 详细信息'; + + @override + String get nodeAddressLabel => '节点地址'; + + @override + String get softwareVersionLabel => '软件版本'; + + @override + String get onlineStatusLabel => '在线状态'; + + @override + String get onlineStatusOnline => '在线'; + + @override + String get onlineStatusOffline => '离线'; + + @override + String get primaryPortLabel => '主端口'; + + @override + String get listeningAddressesLabel => '监听地址:'; + + @override + String get noListeningAddresses => '无'; + + @override + String get leaveNetworkConfirmationTitle => '离开网络?'; + + @override + String leaveNetworkConfirmationText(String networkId) { + return '您确定要离开网络 $networkId 吗?'; + } + + @override + String get confirmButton => '确认'; + + @override + String get cancelButton => '取消'; + + @override + String get networkIdLabel => '网络 ID'; + + @override + String get networkIdHint => '输入 16 位网络 ID'; + + @override + String get invalidNetworkIdTitle => '无效的网络 ID'; + + @override + String get invalidNetworkIdText => '请输入一个 16 位的网络 ID。'; + + @override + String get joinButton => '加入'; + + @override + String get networksTitle => '网络'; + + @override + String get refreshNetworksTooltip => '刷新网络列表'; + + @override + String get noNetworksJoined => '未加入任何网络'; + + @override + String copiedToClipboard(String networkId) { + return '已将 $networkId 复制到剪贴板!'; + } + + @override + String get copyTooltip => '复制'; + + @override + String get leaveTooltip => '离开'; + + @override + String get clearInputTooltip => '清除输入'; + + @override + String get peersFeatureComingSoon => '节点功能即将推出'; + + @override + String get noPeersFound => '未找到节点'; + + @override + String get navBarLabelStatus => '状态'; + + @override + String get navBarLabelNetworks => '网络'; + + @override + String get navBarLabelPeers => '节点'; + + @override + String get appBarTitleStatus => '状态'; + + @override + String get appBarTitleNetworks => '网络'; + + @override + String get appBarTitlePeers => '节点'; + + @override + String joinNetworkSuccessText(String networkId) { + return '成功加入网络 $networkId!'; + } + + @override + String joinNetworkErrorText(String error) { + return '加入网络失败: $error'; + } + + @override + String leaveNetworkSuccessText(String networkId) { + return '成功离开网络 $networkId。'; + } + + @override + String leaveNetworkErrorText(String error) { + return '离开网络失败: $error'; + } + + @override + String get refreshButtonLabel => '重试'; + + @override + String loadPeersErrorText(String error) { + return '加载节点列表失败: $error'; + } + + @override + String loadNetworksErrorText(String error) { + return '加载网络列表失败: $error'; + } + + @override + String get serviceNotRunning => 'ZeroTier服务未运行'; + + @override + String get moduleNotRunning => 'ZeroTier Magisk模块未运行,请检查您是否安装Zerotier Magisk模块。'; + + @override + String get peerTunneled => '中继'; + + @override + String get peerDirect => '直连'; +} diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb new file mode 100644 index 0000000..0063d30 --- /dev/null +++ b/app/lib/l10n/app_zh.arb @@ -0,0 +1,125 @@ +{ + "@@locale": "zh", + "statusRunning": "运行中", + "statusStopped": "已停止", + "refreshStatusTooltip": "刷新状态", + "startButton": "启动", + "restartButton": "重启", + "stopButton": "停止", + "statusDetailsTitle": "ZeroTier 详细信息", + "nodeAddressLabel": "节点地址", + "softwareVersionLabel": "软件版本", + "onlineStatusLabel": "在线状态", + "onlineStatusOnline": "在线", + "onlineStatusOffline": "离线", + "primaryPortLabel": "主端口", + "listeningAddressesLabel": "监听地址:", + "noListeningAddresses": "无", + + "leaveNetworkConfirmationTitle": "离开网络?", + "leaveNetworkConfirmationText": "您确定要离开网络 {networkId} 吗?", + "@leaveNetworkConfirmationText": { + "placeholders": { + "networkId": { + "type": "String", + "example": "1234567890abcdef" + } + } + }, + "confirmButton": "确认", + "cancelButton": "取消", + "networkIdLabel": "网络 ID", + "networkIdHint": "输入 16 位网络 ID", + "invalidNetworkIdTitle": "无效的网络 ID", + "invalidNetworkIdText": "请输入一个 16 位的网络 ID。", + "joinButton": "加入", + "networksTitle": "网络", + "refreshNetworksTooltip": "刷新网络列表", + "noNetworksJoined": "未加入任何网络", + "copiedToClipboard": "已将 {networkId} 复制到剪贴板!", + "@copiedToClipboard": { + "placeholders": { + "networkId": { + "type": "String", + "example": "1234567890abcdef" + } + } + }, + "copyTooltip": "复制", + "leaveTooltip": "离开", + "clearInputTooltip": "清除输入", + "peersFeatureComingSoon": "节点功能即将推出", + "noPeersFound": "未找到节点", + + "navBarLabelStatus": "状态", + "navBarLabelNetworks": "网络", + "navBarLabelPeers": "节点", + "appBarTitleStatus": "状态", + "appBarTitleNetworks": "网络", + "appBarTitlePeers": "节点", + + "joinNetworkSuccessText": "成功加入网络 {networkId}!", + "@joinNetworkSuccessText": { + "placeholders": { + "networkId": { + "type": "String", + "example": "1234567890abcdef" + } + } + }, + "joinNetworkErrorText": "加入网络失败: {error}", + "@joinNetworkErrorText": { + "placeholders": { + "error": { + "type": "String", + "example": "连接超时" + } + } + }, + "leaveNetworkSuccessText": "成功离开网络 {networkId}。", + "@leaveNetworkSuccessText": { + "placeholders": { + "networkId": { + "type": "String", + "example": "1234567890abcdef" + } + } + }, + "leaveNetworkErrorText": "离开网络失败: {error}", + "@leaveNetworkErrorText": { + "placeholders": { + "error": { + "type": "String", + "example": "权限不足" + } + } + }, + "refreshButtonLabel": "重试", + + "loadPeersErrorText": "加载节点列表失败: {error}", + "@loadPeersErrorText": { + "placeholders": { + "error": { + "type": "String", + "example": "网络错误" + } + } + }, + + "loadNetworksErrorText": "加载网络列表失败: {error}", + "@loadNetworksErrorText": { + "placeholders": { + "error": { + "type": "String", + "example": "网络错误" + } + } + }, + + "serviceNotRunning": "ZeroTier服务未运行", + + "moduleNotRunning": "ZeroTier Magisk模块未运行,请检查您是否安装Zerotier Magisk模块。", + + "peerTunneled": "中继", + "peerDirect": "直连" +} \ No newline at end of file diff --git a/app/lib/main.dart b/app/lib/main.dart index 0fcbcdc..ec7fd95 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,14 +1,16 @@ -import 'dart:convert'; import 'package:flutter/material.dart'; -import 'dart:io'; import 'dart:developer' as developer; -import 'package:flutter/services.dart'; -import 'package:quickalert/quickalert.dart'; - -import './authed_client.dart'; -import 'package:path_provider/path_provider.dart'; +import './screens/home_page.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import './l10n/app_localizations.dart'; void main() { + // Add global error handling + FlutterError.onError = (FlutterErrorDetails details) { + FlutterError.presentError(details); + developer.log('Application error', error: details.toString()); + }; + runApp(const MyApp()); } @@ -19,6 +21,16 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'ZeroTier for Magisk', + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('en'), + Locale('zh'), + ], theme: ThemeData( colorSchemeSeed: Colors.lightBlue, useMaterial3: true, @@ -34,300 +46,3 @@ class MyApp extends StatelessWidget { ); } } - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key}); - - @override - State createState() => _MyHomePageState(); -} - -Future zerotierCommand(String command) async { - final path = (await getApplicationDocumentsDirectory()).path; - final file = File('$path/run/pipe'); - await file.writeAsString(command); -} - -class _MyHomePageState extends State { - List networkList = []; - bool runningStatus = false; - - final AuthedClient authed = AuthedClient(); - - loadNetwork() async { - final client = await authed.client; - final resp = await client.get( - Uri.http('localhost:9993', 'network'), - ); - final body = jsonDecode(resp.body); - networkList = List.from(body.map((u) => u['id'])); - } - - void leaveNetwork(String id) async { - final client = await authed.client; - final resp = await client.delete(Uri.http('localhost:9993', 'network/$id')); - if (resp.statusCode != 200) { - // failed - return; - } - // success - setState(() { - networkList.remove(id); - }); - } - - Future joinNetwork(String id) async { - final client = await authed.client; - final resp = await client.put(Uri.http('localhost:9993', 'network/$id')); - if (resp.statusCode != 200) { - // failed - return; - } - // success - if (!networkList.contains(id)) { - setState(() { - networkList.add(id); - }); - } - } - - Future loadStatus() async { - final client = await authed.client; - try { - await client.get(Uri.http('localhost:9993')); - setState(() => runningStatus = true); - return; - } on SocketException { - setState(() => runningStatus = false); - } catch (e) { - developer.log('error', error: e.toString()); - // error - } - } - - late Future loadingNetworkFuture = loadNetwork(); - - @override - void initState() { - super.initState(); - loadStatus(); - } - - Function cmdButtonWrapper(func) { - bool doing = false; - return () => doing - ? null - : () async { - setState(() => doing = true); - await func(); - setState(() => doing = false); - }; - } - - late Function restartFn, startFn, stopFn, joinFn, leaveFn; - - _MyHomePageState() : super() { - restartFn = cmdButtonWrapper(() => zerotierCommand('restart')); - startFn = cmdButtonWrapper(() => zerotierCommand('start')); - stopFn = cmdButtonWrapper(() => zerotierCommand('stop')); - joinFn = cmdButtonWrapper(() => joinNetwork(_idInputController.text)); - } - - final _idInputController = TextEditingController(); - - homePage({bool title = false}) { - if (title) { - return 'Status'; - } - return Column(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Builder(builder: (BuildContext context) { - final color = (runningStatus ? Colors.green : Colors.red)[300]; - - return Column(children: [ - Icon(runningStatus ? Icons.play_arrow : Icons.stop, - color: color, size: 72), - Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(runningStatus ? 'Running' : 'Stopped', - style: TextStyle(color: color, fontSize: 18)), - IconButton(icon: const Icon(Icons.refresh), onPressed: loadStatus) - ]) - ]); - }), - IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - OutlinedButton.icon( - style: const ButtonStyle(alignment: Alignment.centerLeft), - onPressed: startFn(), - label: const Text('Start'), - icon: const Icon(Icons.play_arrow), - ), - OutlinedButton.icon( - style: const ButtonStyle(alignment: Alignment.centerLeft), - onPressed: restartFn(), - label: const Text('Restart'), - icon: const Icon(Icons.restart_alt), - ), - OutlinedButton.icon( - style: const ButtonStyle(alignment: Alignment.centerLeft), - onPressed: stopFn(), - label: const Text('Stop'), - icon: const Icon(Icons.stop), - ), - ], - )) - ]); - } - - networkPage({bool title = false}) { - if (title) { - return 'Network'; - } - return Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - SizedBox( - width: 200, - child: TextField( - controller: _idInputController, - textAlign: TextAlign.center, - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp('[0-9a-f]')) - ], - maxLength: 16, - decoration: const InputDecoration( - labelText: "network id", icon: Icon(Icons.router)))), - OutlinedButton.icon( - onPressed: () { - joinFn()(); - QuickAlert.show( - context: context, - type: QuickAlertType.success, - text: 'Joined ${_idInputController.text}', - ); - }, - label: const Text('Join'), - icon: const Icon(Icons.add)), - ]), - const SizedBox(height: 30), - const Divider(height: 10), - ListTile( - title: const Text('Networks'), - titleTextStyle: const TextStyle(fontSize: 18), - trailing: IconButton( - icon: const Icon(Icons.refresh), - onPressed: () { - setState(() { - loadingNetworkFuture = loadNetwork(); - }); - })), - const SizedBox(height: 10), - FutureBuilder( - future: loadingNetworkFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasError) { - return Text('Error: ${snapshot.error}'); - } - return ListView.builder( - shrinkWrap: true, - itemCount: networkList.length, - itemBuilder: (BuildContext ctx, int i) { - return Card( - child: ListTile( - title: Text(networkList[i]), - trailing: SizedBox( - width: 96, - child: Row(children: [ - IconButton( - onPressed: () { - Clipboard.setData(ClipboardData( - text: networkList[i])); - QuickAlert.show( - context: context, - type: QuickAlertType.success, - text: 'Copied to clipboard!', - ); - }, - tooltip: '复制', - icon: const Icon(Icons.copy)), - IconButton( - onPressed: () => QuickAlert.show( - context: context, - type: QuickAlertType.confirm, - text: - 'Delete network ${networkList[i]} ?', - confirmBtnText: 'Yes', - cancelBtnText: 'No', - confirmBtnColor: Colors.green, - onConfirmBtnTap: () { - leaveNetwork(networkList[i]); - Navigator.pop(context); - }), - tooltip: '离开', - icon: const Icon(Icons.close)), - ])))); - }); - } - return const CircularProgressIndicator(); - }, - ), - ], - )); - } - - peersPage({bool title = false}) { - if (title) { - return 'Peers'; - } - return Container(); - } - - int currentPageIndex = 0; - genWidgetList({bool title = false}) { - return [ - homePage(title: title), - networkPage(title: title), - peersPage(title: title) - ][currentPageIndex]; - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(genWidgetList(title: true))), - bottomNavigationBar: NavigationBar( - onDestinationSelected: (int index) { - setState(() { - currentPageIndex = index; - }); - }, - selectedIndex: currentPageIndex, - destinations: const [ - NavigationDestination( - selectedIcon: Icon(Icons.home), - icon: Icon(Icons.home_outlined), - label: 'Status', - ), - NavigationDestination( - selectedIcon: Icon(Icons.router), - icon: Icon(Icons.router_outlined), - label: 'Network', - ), - NavigationDestination( - selectedIcon: Icon(Icons.account_box), - icon: Icon(Icons.account_box_outlined), - label: 'Peers', - ), - ], - ), - body: Center( - child: genWidgetList(), - )); - } -} diff --git a/app/lib/models/peer_info.dart b/app/lib/models/peer_info.dart new file mode 100644 index 0000000..3c72e41 --- /dev/null +++ b/app/lib/models/peer_info.dart @@ -0,0 +1,82 @@ +import 'dart:math'; // For min function + +class PeerInfo implements Comparable { + final String address; + final int latency; + final String? version; + final String role; + final String? preferredPath; + final bool isPlanet; + final bool tunneled; // 是否通过中继 + + PeerInfo({ + required this.address, + required this.latency, + this.version, + required this.role, + this.preferredPath, + required this.tunneled, + }) : isPlanet = (role == 'PLANET'); + + factory PeerInfo.fromJson(Map json) { + String? bestPath; + List paths = json['paths'] as List? ?? []; + + // Find the preferred active path + var preferred = paths.firstWhere( + (p) => + p is Map && + p['active'] == true && + p['expired'] == false && + p['preferred'] == true, + orElse: () => null); + + if (preferred != null) { + bestPath = preferred['address'] as String?; + } else { + // If no preferred, find the first active path + var firstActive = paths.firstWhere( + (p) => + p is Map && + p['active'] == true && + p['expired'] == false, + orElse: () => null); + if (firstActive != null) { + bestPath = firstActive['address'] as String?; + } + } + + return PeerInfo( + address: json['address'] as String? ?? 'Unknown', + latency: json['latency'] as int, + version: json['version'] as String?, + role: json['role'] as String? ?? 'UNKNOWN', + preferredPath: bestPath, + tunneled: json['tunneled'] as bool, + ); + } + + // Comparison logic for sorting: PLANETs first, then by tunneled (直连优先), then by latency + @override + int compareTo(PeerInfo other) { + if (isPlanet != other.isPlanet) { + return isPlanet ? 1 : -1; + } + + if (tunneled != other.tunneled) { + return tunneled ? -1 : 1; + } + + // 最后按延迟排序(如果有的话) + if (latency != null && other.latency != null) { + return latency!.compareTo(other.latency!); + } else if (latency != null) { + return -1; // 有延迟信息的优先 + } else if (other.latency != null) { + return 1; + } + + // 如果都没有延迟信息,按地址排序 + return address.compareTo(other.address); + } +} \ No newline at end of file diff --git a/app/lib/screens/home_page.dart b/app/lib/screens/home_page.dart new file mode 100644 index 0000000..b5e6d86 --- /dev/null +++ b/app/lib/screens/home_page.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import '../services/zerotier_service.dart'; +import '../components/status_page.dart'; +import '../components/network_page.dart'; +import '../components/peers_page.dart'; +import '../l10n/app_localizations.dart'; + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final ZerotierService _zerotierService = ZerotierService(); + int currentPageIndex = 0; + // Remove state related to specific page data/futures + // Future? _networkFuture; + // Future? _peersFuture; + + @override + void initState() { + super.initState(); + // No initial loading needed here anymore + } + + @override + void dispose() { + // Release service resources + _zerotierService.dispose(); + super.dispose(); + } + + // Remove all data loading and action methods + /* + Future _loadStatus() async { ... } + Future _loadNetworkList() async { ... } + Future _loadPeers() async { ... } + Future _startZeroTier() async { ... } + Future _restartZeroTier() async { ... } + Future _stopZeroTier() async { ... } + Future _joinNetwork(String id) async { ... } + Future _leaveNetwork(String id) async { ... } + */ + + String _getTitle(AppLocalizations l10n) { + switch (currentPageIndex) { + case 0: + return l10n.appBarTitleStatus; + case 1: + return l10n.appBarTitleNetworks; + case 2: + return l10n.appBarTitlePeers; + default: + return 'ZeroTier'; + } + } + + Widget _getCurrentPage() { + switch (currentPageIndex) { + case 0: + // Pass only the service + return StatusPage( + zerotierService: _zerotierService, + // Remove callbacks + // onRefreshStatus: _loadStatus, + // startFunction: _startZeroTier, + // restartFunction: _restartZeroTier, + // stopFunction: _stopZeroTier, + ); + case 1: + // Pass only the service + return NetworkPage( + zerotierService: _zerotierService, + // Remove callbacks + // onRefreshNetworkList: _loadNetworkList, + // onJoinNetwork: _joinNetwork, + // onLeaveNetwork: _leaveNetwork, + ); + case 2: + // Pass only the service + return PeersPage( + zerotierService: _zerotierService, + // Remove callbacks + // onRefreshPeers: _loadPeers, + ); + default: + return const Center(child: Text('Page not found')); + } + } + + @override + Widget build(BuildContext context) { + // Get AppLocalizations instance + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar(title: Text(_getTitle(l10n))), + bottomNavigationBar: NavigationBar( + onDestinationSelected: (int index) { + setState(() { + currentPageIndex = index; + }); + }, + selectedIndex: currentPageIndex, + destinations: [ + NavigationDestination( + selectedIcon: const Icon(Icons.home), + icon: const Icon(Icons.home_outlined), + label: l10n.navBarLabelStatus, + ), + NavigationDestination( + selectedIcon: const Icon(Icons.router), + icon: const Icon(Icons.router_outlined), + label: l10n.navBarLabelNetworks, + ), + NavigationDestination( + selectedIcon: const Icon(Icons.people_alt), + icon: const Icon(Icons.people_alt_outlined), + label: l10n.navBarLabelPeers, + ), + ], + ), + body: _getCurrentPage(), + ); + } +} \ No newline at end of file diff --git a/app/lib/services/auth_service.dart b/app/lib/services/auth_service.dart new file mode 100644 index 0000000..b225e76 --- /dev/null +++ b/app/lib/services/auth_service.dart @@ -0,0 +1,120 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:developer' as developer; + +/// 带有ZeroTier认证的HTTP客户端 +class AuthedDioClient { + final String _authToken; + final Dio _dio; + + /// 创建带有ZeroTier认证的HTTP客户端 + /// + /// [authToken] - ZeroTier认证令牌 + /// [baseUrl] - API基础URL + AuthedDioClient(this._authToken, String baseUrl) : _dio = Dio(BaseOptions( + baseUrl: 'http://$baseUrl', + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + )) { + + // 添加拦截器以处理身份验证 + _dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + // 添加ZeroTier特定认证头 + options.headers['X-ZT1-Auth'] = _authToken; + return handler.next(options); + }, + )); + } + + /// 获取底层的Dio实例 + Dio get dio => _dio; + + /// 发送GET请求 + Future get(String path) { + return _dio.get(path); + } + + /// 发送POST请求 + Future post(String path, {dynamic data}) { + return _dio.post(path, data: data); + } + + /// 发送PUT请求 + Future put(String path, {dynamic data}) { + return _dio.put(path, data: data); + } + + /// 发送DELETE请求 + Future delete(String path) { + return _dio.delete(path); + } + + /// 关闭客户端 + void close() { + _dio.close(); + } +} + +/// ZeroTier认证服务,用于管理认证和客户端创建 +class AuthService { + static const String API_HOST = 'localhost:9993'; + + /// 缓存的HTTP客户端实例 + AuthedDioClient? _cachedClient; + + /// 获取已认证的HTTP客户端 + Future get client async { + // 如果我们已经有缓存的客户端,直接返回 + if (_cachedClient != null) { + return _cachedClient!; + } + + try { + final authToken = await _loadAuthToken(); + _cachedClient = AuthedDioClient(authToken, API_HOST); + return _cachedClient!; + } catch (e) { + developer.log('加载认证令牌失败', error: e.toString()); + rethrow; + } + } + + /// 清理资源 + void dispose() { + if (_cachedClient != null) { + _cachedClient!.close(); + _cachedClient = null; + } + } + + /// 加载认证令牌 + Future _loadAuthToken() async { + try { + final path = (await getApplicationDocumentsDirectory()).path; + final file = File('$path/run/authtoken'); + + // 检查文件是否存在 + if (!await file.exists()) { + throw FileSystemException('认证令牌文件未找到', file.path); + } + + final token = await file.readAsString(); + return token.trim(); + } catch (e) { + developer.log('读取认证令牌失败', error: e.toString()); + rethrow; + } + } +} + +/// 超时异常类 +class TimeoutException implements Exception { + final String message; + + TimeoutException(this.message); + + @override + String toString() => message; +} \ No newline at end of file diff --git a/app/lib/services/zerotier_service.dart b/app/lib/services/zerotier_service.dart new file mode 100644 index 0000000..a30ea53 --- /dev/null +++ b/app/lib/services/zerotier_service.dart @@ -0,0 +1,305 @@ +import 'dart:io'; +import 'dart:developer' as developer; +import 'package:path_provider/path_provider.dart'; +import 'package:dio/dio.dart'; +import 'auth_service.dart'; +import '../models/peer_info.dart'; + +/// ZeroTier服务状态详情 +class ZerotierStatus { + final String address; + final String version; + final bool online; + final int primaryPort; + final List listeningOn; + + ZerotierStatus({ + required this.address, + required this.version, + required this.online, + required this.primaryPort, + required this.listeningOn, + }); + + /// 从JSON创建状态对象 + factory ZerotierStatus.fromJson(Map json) { + // 提取监听地址列表 + List listeningAddresses = []; + if (json['config'] != null && + json['config']['settings'] != null && + json['config']['settings']['listeningOn'] != null) { + listeningAddresses = List.from(json['config']['settings']['listeningOn']); + } + + // 提取主端口 + int port = 0; + if (json['config'] != null && + json['config']['settings'] != null && + json['config']['settings']['primaryPort'] != null) { + port = json['config']['settings']['primaryPort']; + } + + return ZerotierStatus( + address: json['address'] ?? '', + version: json['version'] ?? '', + online: json['online'] ?? false, + primaryPort: port, + listeningOn: listeningAddresses, + ); + } +} + +/// ZeroTier服务,提供所有ZeroTier相关功能 +class ZerotierService { + final AuthService _authService = AuthService(); + + // 内部成员变量,存储最后一次加载的数据 + List _networkList = []; + List _peersList = []; + ZerotierStatus? _statusInfo; + + // 表示ZeroTier服务是否正在运行 + bool runningStatus = false; + + // 允许外部访问最新数据的getter方法 + List get networkList => _networkList; + List get peersList => _peersList; + ZerotierStatus? get statusInfo => _statusInfo; + + /// 向ZeroTier服务发送命令 + Future zerotierCommand(String command) async { + try { + final path = (await getApplicationDocumentsDirectory()).path; + final file = File('$path/run/pipe'); + + if (!await file.exists()) { + developer.log('未找到ZeroTier命令管道: ${file.path}'); + runningStatus = false; + // 特定错误,表示Magisk模块未运行 + throw const FileSystemException('MODULE_NOT_RUNNING'); + } + + await file.writeAsString(command); + developer.log('已发送ZeroTier命令: $command'); + return true; + } on FileSystemException catch (e) { + if (e.message == 'MODULE_NOT_RUNNING') { + developer.log('ZeroTier Magisk模块未运行'); + } else { + developer.log('未找到ZeroTier命令管道,服务可能未运行'); + } + runningStatus = false; + return false; + } catch (e) { + developer.log('发送ZeroTier命令失败', error: e.toString()); + return false; + } + } + + /// 加载网络列表 + Future?> loadNetwork() async { + developer.log('开始加载网络列表'); + try { + final client = await _authService.client; + // 使用相对路径,基础URL已在客户端中配置 + final resp = await client.get('/network'); + + if (resp.statusCode != 200) { + throw HttpException('加载网络列表失败: ${resp.statusCode}'); + } + + final body = resp.data; + _networkList = List.from(body.map((u) => u['id'])); + developer.log('成功加载网络列表: ${_networkList.length} 个网络'); + return _networkList; + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.connectionError) { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + } else { + developer.log('加载网络列表失败', error: e.toString()); + } + _networkList = []; + return null; + } on SocketException { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + _networkList = []; + return null; + } catch (e) { + developer.log('加载网络列表失败', error: e.toString()); + _networkList = []; + return null; + } + } + + /// 离开指定网络 + Future leaveNetwork(String id) async { + try { + final client = await _authService.client; + // 使用相对路径 + final resp = await client.delete('/network/$id'); + final success = resp.statusCode == 200; + + if (success) { + developer.log('成功离开网络: $id'); + } else { + developer.log('离开网络失败: $id, 状态码: ${resp.statusCode}'); + } + + return success; + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.connectionError) { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + // 更新运行状态 + runningStatus = false; + } else { + developer.log('离开网络错误', error: e.toString()); + } + return false; + } on SocketException { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + // 更新运行状态 + runningStatus = false; + return false; + } catch (e) { + developer.log('离开网络错误', error: e.toString()); + return false; + } + } + + /// 加入指定网络 + Future joinNetwork(String id) async { + try { + if (id.isEmpty) { + developer.log('无法加入空网络ID'); + return false; + } + + final client = await _authService.client; + // 使用相对路径 + final resp = await client.put('/network/$id'); + final success = resp.statusCode == 200; + + if (success) { + developer.log('成功加入网络: $id'); + } else { + developer.log('加入网络失败: $id, 状态码: ${resp.statusCode}'); + } + + return success; + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.connectionError) { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + // 更新运行状态 + runningStatus = false; + } else { + developer.log('加入网络错误', error: e.toString()); + } + return false; + } on SocketException { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + // 更新运行状态 + runningStatus = false; + return false; + } catch (e) { + developer.log('加入网络错误', error: e.toString()); + return false; + } + } + + /// 加载 Peer 列表 + Future?> loadPeers() async { + developer.log('开始加载 Peer 列表'); + try { + final client = await _authService.client; + final resp = await client.get('/peer'); + + if (resp.statusCode != 200) { + throw HttpException('加载 Peer 列表失败: ${resp.statusCode}'); + } + + final List rawPeers = resp.data; + _peersList = rawPeers + .map((p) => PeerInfo.fromJson(p as Map)) + .toList(); + + // Sort the list (PLANETs first, then LEAFs by address) + _peersList.sort(); + + developer.log('成功加载 Peer 列表: ${_peersList.length} 个 Peers'); + return _peersList; + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.connectionError) { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + } else { + developer.log('加载 Peer 列表 Dio 错误', error: e.toString(), stackTrace: e.stackTrace); + } + _peersList = []; + return null; + } on SocketException { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + _peersList = []; + return null; + } catch (e, s) { + developer.log('加载 Peer 列表失败', error: e.toString(), stackTrace: s); + _peersList = []; + return null; + } + } + + /// 加载ZeroTier服务状态 + Future loadStatus() async { + developer.log('检查ZeroTier服务状态'); + Map? statusData; + + try { + // 尝试获取状态信息 + final client = await _authService.client; + final response = await client.get('/status'); + + if (response.statusCode == 200) { + statusData = response.data; + developer.log('已获取ZeroTier状态信息'); + } else { + developer.log('获取ZeroTier状态信息失败: ${response.statusCode}'); + statusData = null; + } + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.connectionError) { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + } else { + developer.log('获取ZeroTier状态信息错误', error: e.toString()); + } + statusData = null; + } on SocketException { + developer.log('无法连接到ZeroTier服务:服务可能未运行'); + statusData = null; + } catch (e) { + developer.log('检查ZeroTier状态错误', error: e.toString()); + statusData = null; + } + + // 根据获取的状态信息更新运行状态和详情 + if (statusData != null) { + runningStatus = true; + _statusInfo = ZerotierStatus.fromJson(statusData); + developer.log('ZeroTier服务状态: 运行中'); + developer.log('已加载ZeroTier详细状态信息'); + return _statusInfo; + } else { + runningStatus = false; + _statusInfo = null; + developer.log('ZeroTier服务状态: 未运行'); + return null; + } + } + + /// 释放资源 + void dispose() { + _authService.dispose(); + } +} \ No newline at end of file diff --git a/app/pubspec.lock b/app/pubspec.lock index a7050fe..a7569de 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -5,42 +5,42 @@ packages: dependency: transitive description: name: archive - sha256: ecf4273855368121b1caed0d10d4513c7241dfc813f7d3c8933b36622ae9b265 + sha256: "7dcbd0f87fe5f61cb28da39a1a8b70dbc106e2fe0516f7836eb7bb2948481a12" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "3.5.1" + version: "4.0.5" args: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.5.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.11.0" + version: "2.13.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.3.0" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -53,34 +53,34 @@ packages: dependency: transitive description: name: cli_util - sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "0.4.1" + version: "0.4.2" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.18.0" + version: "1.19.1" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "3.0.3" + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -89,22 +89,38 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "1.0.8" + dio: + dependency: "direct main" + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.1" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.1.2" + version: "2.1.4" flutter: dependency: "direct main" description: flutter @@ -114,47 +130,52 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "0.13.1" + version: "0.14.3" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "4.0.0" + version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" - http: - dependency: "direct main" - description: - name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" - url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" - source: hosted - version: "1.2.1" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "4.0.2" + version: "4.1.2" image: dependency: transitive description: name: image - sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "4.1.7" + version: "4.5.4" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.19.0" json_annotation: dependency: transitive description: @@ -175,18 +196,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "10.0.4" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "3.0.3" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -199,66 +220,66 @@ packages: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "4.0.0" + version: "5.1.1" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.12.0" + version: "1.16.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.9.0" + version: "1.9.1" path_provider: dependency: "direct main" description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.1.3" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.2.4" + version: "2.2.16" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.4.0" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -279,26 +300,26 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.2.1" + version: "2.3.0" petitparser: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "6.0.2" + version: "6.1.0" platform: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "3.1.5" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -307,6 +328,14 @@ packages: url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "6.0.1" quickalert: dependency: "direct main" description: @@ -319,63 +348,63 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.2.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "0.7.0" + version: "0.7.4" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.3.2" + version: "1.4.0" vector_math: dependency: transitive description: @@ -388,34 +417,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "14.2.1" + version: "14.3.1" web: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "0.5.1" - win32: - dependency: transitive - description: - name: win32 - sha256: "0eaf06e3446824099858367950a813472af675116bf63f008a4c2a75ae13e9cb" - url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" - source: hosted - version: "5.5.0" + version: "1.1.1" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "1.0.4" + version: "1.1.0" xml: dependency: transitive description: @@ -428,10 +449,10 @@ packages: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.3.4 <4.0.0" - flutter: ">=3.16.6" + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 9f8d668..0b51178 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -30,12 +30,15 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter + intl: any # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.6 - http: ^1.2.1 + dio: ^5.4.0 path_provider: ^2.1.3 quickalert: ^1.1.0 @@ -48,8 +51,8 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^4.0.0 - flutter_launcher_icons: ^0.13.1 + flutter_lints: ^5.0.0 + flutter_launcher_icons: ^0.14.3 launcher_name: ^1.0.2 # For information on the generic Dart part of this file, see the @@ -62,6 +65,7 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + generate: true # To add assets to your application, add an assets section, like this: # assets: