diff --git a/.github/workflows/flutter_web_build.yml b/.github/workflows/flutter_web_build.yml index 8c64d2d..42cc147 100644 --- a/.github/workflows/flutter_web_build.yml +++ b/.github/workflows/flutter_web_build.yml @@ -13,12 +13,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.24.3' + flutter-version: '3.32.8' channel: 'stable' - name: Build web diff --git a/.gitignore b/.gitignore index 29a3a50..ff69a8e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related @@ -41,3 +43,8 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +/android/app/.cxx/ + +/macos/DerivedData/ + +/dist \ No newline at end of file diff --git a/README.md b/README.md index 1f787ae..099328d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Hosts Editor 是一个使用 Flutter 开发的应用程序,旨在简化 Linux ## 特性 -- **跨平台支持**:虽然目前只支持 Linux、Windows、Web 系统,但未来计划扩展到其他操作系统。 +- **跨平台支持**:虽然目前只支持 Linux、Windows、MacOS、Web 系统,但未来计划扩展到其他操作系统。 - **直观的用户界面**:使用 Flutter 构建,提供流畅的用户体验。 - **实时预览**:在编辑 hosts 文件时,实时查看更改效果。 - **安全性**:确保对 hosts 文件的修改是安全的,避免不必要的错误。 @@ -35,4 +35,8 @@ hosts /etc/hosts ### 测试是否配置成功 -![6.png](image/6.png) \ No newline at end of file +![6.png](image/6.png) + +### 导入导出 + +![7.png](image/7.png) \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 53ed703..b1fe309 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -5,10 +5,18 @@ plugins { id "dev.flutter.flutter-gradle-plugin" } +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + android { namespace = "top.webb_l.hosts" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion +// ndkVersion = flutter.ndkVersion + + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -19,6 +27,15 @@ android { jvmTarget = JavaVersion.VERSION_1_8 } + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "top.webb_l.hosts" @@ -32,9 +49,7 @@ android { buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.debug + signingConfig = signingConfigs.release } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3d19912..b5cc9c6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,45 +1,68 @@ + + + + + + + + + + + + android:icon="@mipmap/ic_launcher" + android:label="hosts"> + + + + + + - + to determine the Window background behind the Flutter UI. + --> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> + - - + + + - + - - - - - - - - + \ No newline at end of file diff --git a/android/app/src/main/java/top/webb_l/hosts/HostsVpnService.kt b/android/app/src/main/java/top/webb_l/hosts/HostsVpnService.kt new file mode 100644 index 0000000..cb60a37 --- /dev/null +++ b/android/app/src/main/java/top/webb_l/hosts/HostsVpnService.kt @@ -0,0 +1,57 @@ +package top.webb_l.hosts + +import android.content.Intent +import android.net.VpnService +import android.os.ParcelFileDescriptor +import android.util.Log +import java.io.FileInputStream +import java.io.FileOutputStream +import kotlin.concurrent.thread + +class HostsVpnService : VpnService() { + + private var vpnInterface: ParcelFileDescriptor? = null + + override fun onDestroy() { + super.onDestroy() + // 清理资源 + vpnInterface?.close() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // 启动 VPN + startVpn() + return START_STICKY + } + + private fun startVpn() { + // 配置 VPN + val builder = Builder() + builder.setSession("MyVPN") + .addAddress("10.0.0.2", 24) // VPN 地址 + .addRoute("0.0.0.0", 0) // 路由 + + vpnInterface = builder.establish() + + // 这里是处理网络流量的逻辑 + // 你需要实现读取数据包、解析 DNS 请求、根据 hosts 文件进行重定向等 + Log.e("TAG", "run: ${vpnInterface}") + val inputStream = FileInputStream(vpnInterface!!.fileDescriptor) + val outputStream = FileOutputStream(vpnInterface!!.fileDescriptor) + + val buffer = ByteArray(32767) + thread { + while (true) { + // 读取数据包 + val length = inputStream.read(buffer) + if (length > 0) { + // 处理数据包 + // 这里可以添加 DNS 解析和 hosts 文件的处理逻辑 + Log.e("TAG", "startVpn: ${String(buffer, 0, length)}", ) + // 将数据包写回输出流 + outputStream.write(buffer, 0, length) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/top/webb_l/hosts/MainActivity.kt b/android/app/src/main/kotlin/top/webb_l/hosts/MainActivity.kt index f262e6f..f505603 100644 --- a/android/app/src/main/kotlin/top/webb_l/hosts/MainActivity.kt +++ b/android/app/src/main/kotlin/top/webb_l/hosts/MainActivity.kt @@ -1,5 +1,29 @@ package top.webb_l.hosts +import android.content.Intent +import android.net.VpnService +import android.util.Log import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity: FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + val intent = VpnService.prepare(this) + startService(Intent(this, HostsVpnService::class.java)) + Log.e("TAG", "configureFlutterEngine: ", ) +// if (intent != null) { +// startActivityForResult(intent, 0) +// } else { +// onActivityResult(0, RESULT_OK, null) +// } + super.configureFlutterEngine(flutterEngine) + } + + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode == RESULT_OK) { + startService(Intent(this, HostsVpnService::class.java)) + } + super.onActivityResult(requestCode, resultCode, data) + } +} diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..c442385 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..295851a 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..c203237 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..3369c4e 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..a8ff1eb 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/build.gradle b/android/build.gradle index d2ffbff..69776d8 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,3 +1,12 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10' + } +} allprojects { repositories { google() diff --git a/android/gradle.properties b/android/gradle.properties index 2597170..db24e8b 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,6 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e1ca574..09523c0 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/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.9-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/android/settings.gradle b/android/settings.gradle index 536165d..a4fea4d 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,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.7.2' apply false + id "org.jetbrains.kotlin.android" version "1.8.10" apply false } include ":app" diff --git a/image/1.png b/image/1.png index b56dc2b..0a15fc6 100644 Binary files a/image/1.png and b/image/1.png differ diff --git a/image/2.png b/image/2.png index a588f10..bac2ecc 100644 Binary files a/image/2.png and b/image/2.png differ diff --git a/image/3.png b/image/3.png index e8edb65..c674531 100644 Binary files a/image/3.png and b/image/3.png differ diff --git a/image/4.png b/image/4.png index 22f2129..d04e496 100644 Binary files a/image/4.png and b/image/4.png differ diff --git a/image/7.png b/image/7.png new file mode 100644 index 0000000..ace01ea Binary files /dev/null and b/image/7.png differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 84e7b66..bfc2bd9 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -427,7 +427,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -484,7 +484,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..d0d98aa 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..62f508e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..79e5e96 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..b5d85b8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..a956c53 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..02dd760 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..ff52e06 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..3cb987f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..b5d85b8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..8b4535f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..25417e9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..44bd318 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..25547c3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..0aea464 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..ed12be9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..25417e9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..aceff94 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..62bb10d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..f613590 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..7e86d99 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..b0ac857 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..d4010ba 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/l10n.yaml b/l10n.yaml index 4e6692e..9fcf641 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,3 +1,4 @@ arb-dir: lib/l10n -template-arb-file: app_en.arb -output-localization-file: app_localizations.dart \ No newline at end of file +template-arb-file: app_zh.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..ab3fe9c --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:hosts/home/view/home_page.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/global_settings.dart'; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/server/server_manager.dart'; +import 'package:hosts/theme.dart'; +import 'package:hosts/util/file_manager.dart'; +import 'package:hosts/util/settings_manager.dart'; + +class HostsApp extends MaterialApp { + final String filePath; + + HostsApp(this.filePath, {super.key}) + : super( + onGenerateTitle: (context) => AppLocalizations.of(context)!.app_name, + // locale: Locale("en"), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData( + brightness: Brightness.light, + colorScheme: MaterialTheme.lightScheme(), + useMaterial3: true, + ), + darkTheme: ThemeData( + brightness: Brightness.dark, + colorScheme: MaterialTheme.darkScheme(), + useMaterial3: true, + ), + themeMode: ThemeMode.system, + home: _platformSpecificWidget(filePath), + ); +} + +Widget _platformSpecificWidget(String filePath) { + if (filePath.isNotEmpty) { + GlobalSettings().filePath = filePath; + } + if (GlobalSettings().filePath != null) { + return SimpleHomePage(); + } else { + return FutureBuilder( + future: _initializeApp(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else { + return const HomePage(); + } + }, + ); + } +} + +Future _initializeApp() async { + SettingsManager settingsManager = SettingsManager(); + FileManager fileManager = FileManager(); + + bool firstOpenApp = await settingsManager.getBool(settingKeyFirstOpenApp); + if (!firstOpenApp) { + const String fileName = "system"; + await fileManager.createHosts(fileName); + await settingsManager.setList(settingKeyHostConfigs, + [SimpleHostFile(fileName: fileName, remark: "")]); + await settingsManager.setString(settingKeyUseHostFile, fileName); + File(FileManager.systemHostFilePath) + .copy(await fileManager.getHostsFilePath(fileName)); + settingsManager.setBool(settingKeyFirstOpenApp, true); + } + + // 异步启动服务器,不阻塞应用初始化 + _startServerInBackground(settingsManager); +} + +/// 在后台异步启动服务器,不阻塞应用初始化 +void _startServerInBackground(SettingsManager settingsManager) { + ServerManager serverManager = ServerManager(); + + Future.microtask(() async { + try { + // 检查是否启用了自动启动服务器 + bool isAutoStartEnabled = + await settingsManager.getBool(settingKeyAutoStartEnabled); + if (isAutoStartEnabled) { + // 获取保存的hosts文件列表 + List savedHostsList = + await settingsManager.getList(settingKeyAutoStartHosts); + + if (savedHostsList.isNotEmpty) { + // 将JSON数据转换为SimpleHostFile对象 + List autoStartHosts = savedHostsList + .map((json) => SimpleHostFile.fromJson(json)) + .toList(); + + // 启动服务器 + await serverManager.startServer( + allowedHostFiles: + autoStartHosts.map((host) => host.fileName).toList(), + ); + + print('自动启动服务器成功,共享${autoStartHosts.length}个hosts文件'); + } else { + print('自动启动已启用,但没有找到保存的hosts文件列表'); + } + } + } catch (e) { + print('自动启动服务器失败: $e'); + } + }); +} diff --git a/lib/enums.dart b/lib/enums.dart index 00feeb2..6d3182d 100644 --- a/lib/enums.dart +++ b/lib/enums.dart @@ -1,3 +1,12 @@ enum EditMode { Text, Table } enum AdvancedSettingsEnum { Open, Close } + +/// 排序方向枚举 +enum SortDirection { + /// 升序 + ascending, + + /// 降序 + descending, +} \ No newline at end of file diff --git a/lib/home/cubit/home_cubit.dart b/lib/home/cubit/home_cubit.dart new file mode 100755 index 0000000..ab09c78 --- /dev/null +++ b/lib/home/cubit/home_cubit.dart @@ -0,0 +1,304 @@ +import 'package:bloc/bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:hosts/enums.dart'; +import 'package:hosts/l10n/app_localizations.dart' as gen; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/util/file_manager.dart'; +import 'package:hosts/util/settings_manager.dart'; +import 'package:hosts/util/string_util.dart'; + +part 'home_state.dart'; + +/// 首页业务逻辑处理类 +/// 管理首页的所有状态变更 +class HomeCubit extends Cubit { + /// 构造函数 + /// 初始化状态为HomeInitial + HomeCubit() : super(const HomeInitial(HomeStateData())); + + /// 设置管理器 + final SettingsManager _settingsManager = SettingsManager(); + + /// 文件管理器 + final FileManager _fileManager = FileManager(); + + /// 加载host文件列表 + /// [context] 用于本地化 + /// [isInit] 是否为初始化加载 + Future loadHostFiles(BuildContext context, + [bool isInit = false]) async { + List tempHostFiles = []; + List tempSelectHostFiles = []; + List hostConfigs = + await _settingsManager.getList(settingKeyHostConfigs); + + if (isInit) { + // TODO 支持多个文件选择 + tempSelectHostFiles = [ + (await _settingsManager.getString(settingKeyUseHostFile) ?? "") + ]; + } + + for (Map config in hostConfigs) { + SimpleHostFile hostFile = SimpleHostFile.fromJson(config); + tempHostFiles.add(hostFile); + + if (hostFile.fileName == "system") { + hostFile.remark = gen.AppLocalizations.of(context)!.default_hosts_text; + } + } + + final fileId = + tempSelectHostFiles.isNotEmpty ? tempSelectHostFiles.first : "system"; + + emit( + HomeInitial( + HomeStateData( + hostFiles: tempHostFiles, + useHostFiles: tempSelectHostFiles, + editMode: EditMode.Table, + ), + ), + ); + + selectHost(fileId); + } + + /// 添加新的host文件 + /// [remark] 文件备注信息 + Future addHostFile(String remark) async { + if (remark.isEmpty) return; + + // 获取当前 hostFiles 列表 + List currentHostFiles = List.from(state.data.hostFiles); + List hostConfigs = + await _settingsManager.getList(settingKeyHostConfigs); + + // 生成随机文件名 + final String fileName = generateRandomString(18); + + // 创建新的 hostFile + SimpleHostFile newHostFile = + SimpleHostFile(fileName: fileName, remark: remark); + + // 添加到 hostFiles 列表 + currentHostFiles.add(newHostFile); + hostConfigs.add(newHostFile.toJson()); + + // 创建实际的文件 + await _fileManager.createHosts(fileName); + + // 保存到 + await _settingsManager.setList(settingKeyHostConfigs, hostConfigs); + + // 更新状态 + emit( + HomeInitial( + state.data.copyWith( + hostFiles: currentHostFiles, + ), + ), + ); + } + + /// 更新host文件备注 + /// [fileName] 要更新的文件名 + /// [newRemark] 新的备注信息 + Future updateHostFileRemark(String fileName, String newRemark) async { + if (fileName.isEmpty || newRemark.isEmpty) return; + + List updatedHostFiles = []; + List hostConfigs = + await _settingsManager.getList(settingKeyHostConfigs); + + bool updated = false; + + // 更新 hostFiles + for (SimpleHostFile hostFile in state.data.hostFiles) { + if (hostFile.fileName == fileName) { + updatedHostFiles + .add(SimpleHostFile(fileName: fileName, remark: newRemark)); + updated = true; + } else { + updatedHostFiles.add(hostFile); + } + } + + if (!updated) return; + + // 更新 hostConfigs + List> updatedConfigs = []; + for (var config in hostConfigs) { + if (config['fileName'] == fileName) { + updatedConfigs.add({'fileName': fileName, 'remark': newRemark}); + } else { + updatedConfigs.add(config); + } + } + + // 保存到 settings + await _settingsManager.setList(settingKeyHostConfigs, updatedConfigs); + + // 更新状态 + emit( + HomeInitial( + state.data.copyWith( + hostFiles: updatedHostFiles, + ), + ), + ); + } + + /// 删除host文件 + /// [fileName] 要删除的文件名 + Future deleteHostFile(String fileName) async { + if (fileName.isEmpty || fileName == "system") return; + + // 判断是否删除使用的 Host 文件 + final bool isDeleteUse = state.data.useHostFiles.contains(fileName); + // 判断是否删除选择的 Host 文件 + final bool isDeleteSelect = state.data.selectHostFile == fileName; + + if (isDeleteUse) { + if (!await useHost("system")) { + return; + } + } + + List updatedHostFiles = state.data.hostFiles + .where((file) => file.fileName != fileName) + .toList(); + + List hostConfigs = + await _settingsManager.getList(settingKeyHostConfigs); + + hostConfigs.removeWhere((config) => config['fileName'] == fileName); + + // 保存到 settings + await _settingsManager.setList(settingKeyHostConfigs, hostConfigs); + + // 删除实际文件 + await _fileManager.deleteFiles([fileName]); + + // 更新状态 + emit( + HomeDelete( + state.data.copyWith( + hostFiles: updatedHostFiles, + selectHostFile: isDeleteSelect ? "system" : null, + ), + ), + ); + } + + /// 选择host文件 + /// [fileId] 要选择的文件ID + Future selectHost(String fileId) async { + emit( + HomeSelectHostFileChanged( + state.data.copyWith( + selectHostFile: fileId, + ), + ), + ); + } + + /// 使用host文件 + /// [fileName] 要使用的文件名 + Future useHost(String fileName) async { + final hostPath = await _fileManager.getHostsFilePath(fileName); + try { + await _fileManager.writeFileWithAdminPrivileges( + hostPath, FileManager.systemHostFilePath); + + // 更新使用的hosts文件列表 + List updatedUseHostFiles = [fileName]; + + // 保存到设置 + await _settingsManager.setString(settingKeyUseHostFile, fileName); + + // 更新状态 + emit( + HomeInitial( + state.data.copyWith( + useHostFiles: updatedUseHostFiles, + ), + ), + ); + + return true; + } catch (e) { + return false; + } + } + + /// 切换编辑模式 + /// [editMode] 新的编辑模式(表格/文本) + Future toggleEditMode(EditMode editMode) async { + emit( + HomeEditMode( + state.data.copyWith( + editMode: editMode, + ), + ), + ); + } + + /// 切换高级设置状态 + /// [advancedSettings] 新的高级设置状态 + Future toggleAdvancedSettings( + AdvancedSettingsEnum advancedSettings) async { + emit( + HomeAdvancedSettings( + state.data.copyWith( + advancedSettingsEnum: advancedSettings, + ), + ), + ); + } + + /// 切换高级设置开关状态 + /// 在Open和Close之间切换 + Future toggleAdvancedSettingsSwitch() async { + final AdvancedSettingsEnum newSettings = + state.data.advancedSettingsEnum == AdvancedSettingsEnum.Close + ? AdvancedSettingsEnum.Open + : AdvancedSettingsEnum.Close; + + await toggleAdvancedSettings(newSettings); + } + + /// 刷新hosts文件列表 + /// [context] 可选的context参数,用于system文件的本地化 + Future refreshHostFiles([BuildContext? context]) async { + final String? defaultHostsText = context != null + ? gen.AppLocalizations.of(context)!.default_hosts_text + : null; + + List tempHostFiles = []; + List hostConfigs = + await _settingsManager.getList(settingKeyHostConfigs); + + for (Map config in hostConfigs) { + SimpleHostFile hostFile = SimpleHostFile.fromJson(config); + tempHostFiles.add(hostFile); + + // 特殊处理system文件的remark + if (hostFile.fileName == "system") { + if (defaultHostsText != null) { + hostFile.remark = defaultHostsText; + } else { + hostFile.remark = "默认"; // 后备文本 + } + } + } + + emit( + HomeInitial( + state.data.copyWith( + hostFiles: tempHostFiles, + ), + ), + ); + } +} diff --git a/lib/home/cubit/home_state.dart b/lib/home/cubit/home_state.dart new file mode 100644 index 0000000..0875223 --- /dev/null +++ b/lib/home/cubit/home_state.dart @@ -0,0 +1,105 @@ +part of 'home_cubit.dart'; + +/// 首页状态数据类 +/// 保存所有与首页相关的状态属性 +/// 首页状态数据模型 +class HomeStateData { + /// 所有host文件列表 + final List hostFiles; + + /// 当前使用的host文件列表 + final List useHostFiles; + + /// 当前选中的host文件 + final String selectHostFile; + + /// 编辑模式(表格/文本) + final EditMode editMode; + + /// 高级设置状态 + final AdvancedSettingsEnum advancedSettingsEnum; + + /// 搜索文本 + final String searchText; + + /// 构造函数 + const HomeStateData( + {this.hostFiles = const [], + this.useHostFiles = const [], + this.selectHostFile = "", + this.editMode = EditMode.Table, + this.advancedSettingsEnum = AdvancedSettingsEnum.Close, + this.searchText = ""}); + + /// 复制方法 + /// 用于基于当前状态创建新状态 + HomeStateData copyWith({ + List? hostFiles, + List? useHostFiles, + String? selectHostFile, + EditMode? editMode, + AdvancedSettingsEnum? advancedSettingsEnum, + String? searchText, + }) { + return HomeStateData( + hostFiles: hostFiles ?? this.hostFiles, + useHostFiles: useHostFiles ?? this.useHostFiles, + selectHostFile: selectHostFile ?? this.selectHostFile, + editMode: editMode ?? this.editMode, + advancedSettingsEnum: advancedSettingsEnum ?? this.advancedSettingsEnum, + searchText: searchText ?? this.searchText, + ); + } +} + +/// 首页状态基类 +/// 使用密封类设计模式限制状态类型 +/// 不可变状态基类 +@immutable +sealed class HomeState { + final HomeStateData data; + + const HomeState(this.data); +} + +/// 初始状态 +class HomeInitial extends HomeState { + const HomeInitial(super.data); +} + +class HomeAdvancedSettings extends HomeState { + const HomeAdvancedSettings(super.data); +} + +/// 编辑模式变更状态 +/// 当用户切换编辑模式(表格/文本)时触发 +class HomeEditMode extends HomeState { + const HomeEditMode(super.data); +} + +/// 数据加载完成状态 +class HomeLoaded extends HomeState { + const HomeLoaded(super.data); +} + +/// 数据加载中状态 +class HomeLoading extends HomeState { + const HomeLoading(super.data); +} + +/// 错误状态 +/// 包含错误信息 +class HomeError extends HomeState { + final String message; + const HomeError(super.data, this.message); +} + +/// 选择的host文件变更状态 +class HomeSelectHostFileChanged extends HomeState { + const HomeSelectHostFileChanged(super.data); +} + +/// 删除操作状态 +class HomeDelete extends HomeState { + const HomeDelete(super.data); +} diff --git a/lib/home/cubit/host_cubit.dart b/lib/home/cubit/host_cubit.dart new file mode 100644 index 0000000..809e7d9 --- /dev/null +++ b/lib/home/cubit/host_cubit.dart @@ -0,0 +1,639 @@ +/// 主机管理模块的Cubit实现 +/// +/// 负责管理主机文件的状态和业务逻辑 +library; + +import 'dart:io'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hosts/enums.dart'; +import 'package:hosts/home/cubit/home_cubit.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/global_settings.dart'; +import 'package:hosts/model/host_file.dart'; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/util/file_manager.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +part 'host_state.dart'; + +// 确保已正确导入HostSort状态类 +/// 主机管理Cubit类 +/// +/// 继承自Cubit,管理主机文件的各种状态变更 +class HostCubit extends Cubit { + /// 构造函数 + /// + /// 初始化状态为HostInitial + HostCubit() : super(HostInitial(HostStateData())); + + /// 文件管理器实例 + final FileManager _fileManager = FileManager(); + + /// 更新搜索文本 + /// + /// [text]: 新的搜索文本 + void updateSearchText(String text) { + emit( + HostFilter( + state.data.copyWith( + searchText: text, + filterHosts: + state.data.hosts.where((host) => host.filter(text)).toList(), + ), + ), + ); + } + + /// 更新选中的主机列表 + /// + /// [selectHosts]: 新的选中主机列表 + void updateSelectHosts(List selectHosts) { + emit( + HostInitial( + state.data.copyWith( + selectHosts: selectHosts, + ), + ), + ); + } + + /// 更新全选状态 + /// + /// [isCheckedAll]: 是否全选 + void updateCheckedAll(bool? isCheckedAll) { + emit( + HostInitial( + state.data.copyWith( + selectHosts: isCheckedAll == true ? state.data.filterHosts : [], + ), + ), + ); + } + + /// 更新主机文件内容 + /// + /// [fileId]: 文件ID + void updateHost(String fileId) async { + final content = await _fileManager.readAsString(fileId); + final hosts = _fileManager.parseHosts(content.split("\n")); + final history = await _fileManager.getHistory(fileId); + + emit( + HostInitial( + HostStateData( + fileId: fileId, + fileContent: content, + defaultFileContent: content, + hosts: hosts, + defaultHosts: hosts, + filterHosts: hosts, + history: history, + ), + ), + ); + } + + /// 更新编辑模式 + /// + /// [editMode]: 新的编辑模式(表格/文本) + /// 根据编辑模式切换显示方式 + void updateEditMode(EditMode editMode) { + if (editMode == EditMode.Table) { + final hosts = _fileManager.parseHosts(state.data.fileContent.split("\n")); + emit( + HostEditMode( + state.data.copyWith( + hosts: hosts, + filterHosts: hosts, + ), + ), + ); + } + if (editMode == EditMode.Text) { + emit( + HostEditMode( + state.data.copyWith( + fileContent: toString(), + ), + ), + ); + } + } + + /// 编辑主机项 + /// + /// [index]: 主机索引 + /// [host]: 新的主机数据 + void onEdit(int index, HostsModel host) { + final List updatedHosts = + List.from(state.data.hosts); + + HostsModel oldHost = updatedHosts[index]; + + host.descLine ??= oldHost.descLine; + host.hostLine = oldHost.hostLine; + + final lines = state.data.fileContent.split("\n"); + + final List newLine = host.toString().split("\n"); + if (host.descLine != null && + host.descLine! > -1 && + [2].contains(newLine.length)) { + lines[host.descLine!] = newLine[0]; + } + + if (host.hostLine != null && + host.hostLine! > -1 && + [1, 2].contains(newLine.length)) { + lines[host.hostLine!] = newLine.length == 2 ? newLine[1] : newLine[0]; + } + + // 新增备注 + if (host.descLine == null && host.description.isNotEmpty) { + lines.insert(host.hostLine!, "# ${host.description}"); + } + + // 移除备注 + if (host.descLine != null && host.description.isEmpty) { + lines.removeAt(host.descLine!); + } + + updatedHosts[index] = host; + + emit( + HostEdit( + state.data.copyWith( + hosts: updatedHosts, + filterHosts: updatedHosts + .where((host) => host.filter(state.data.searchText)) + .toList(), + isSave: isUpdate(updatedHosts), + fileContent: lines.join("\n"), + ), + ), + ); + } + + /// 切换主机使用状态 + /// + /// [hostsMap]: 主机映射关系 + void onToggleUse(Map hostsMap) { + final lines = state.data.fileContent.split("\n"); + + final List updatedHosts = state.data.hosts.map((host) { + return hostsMap.containsKey(host) ? hostsMap[host]! : host; + }).toList(); + + for (var host in updatedHosts) { + if (host.hostLine == null) { + continue; + } + lines[host.hostLine!] = host.toHostString(); + } + + emit( + HostToggleUse( + state.data.copyWith( + hosts: updatedHosts, + filterHosts: updatedHosts + .where((host) => host.filter(state.data.searchText)) + .toList(), + selectHosts: state.data.selectHosts.isNotEmpty + ? hostsMap.values.toList() + : [], + isSave: isUpdate(updatedHosts), + fileContent: lines.join("\n")), + ), + ); + } + + /// 删除主机项 + /// + /// [hosts]: 要删除的主机列表 + void onDelete(List hosts) { + final newHosts = + state.data.hosts.where((host) => !hosts.contains(host)).toList(); + hosts.sort((a, b) => a.hostLine?.compareTo(b.hostLine ?? -1) ?? 1); + final lines = state.data.fileContent.split("\n"); + + int removeCount = 0; + for (var host in hosts) { + if (host.descLine != null && host.descLine == host.hostLine) { + lines.removeAt(host.descLine! - removeCount); + removeCount++; + continue; + } + + if (host.descLine != null && host.descLine! > -1) { + lines.removeAt(host.descLine! - removeCount); + removeCount++; + } + if (host.hostLine != null && host.hostLine! > -1) { + lines.removeAt(host.hostLine! - removeCount); + removeCount++; + } + } + emit( + HostDelete( + state.data.copyWith( + hosts: newHosts, + filterHosts: newHosts + .where((host) => host.filter(state.data.searchText)) + .toList(), + selectHosts: state.data.selectHosts + .where((host) => !hosts.contains(host)) + .toList(), + isSave: isUpdate(newHosts), + fileContent: lines.join("\n"), + ), + ), + ); + } + + /// 选中/取消选中主机项 + /// + /// [index]: 主机索引 + /// [host]: 主机数据 + void onChecked(int index, HostsModel host) { + final List updatedSelectHosts = + List.from(state.data.selectHosts); + if (updatedSelectHosts.contains(host)) { + updatedSelectHosts.remove(host); + } else { + updatedSelectHosts.add(host); + } + + emit( + HostChecked( + state.data.copyWith( + selectHosts: updatedSelectHosts, + ), + ), + ); + } + + /// 排序主机列表 + /// + /// [columnName]: 列名 + /// [sortDirection]: 排序方向 + void onSort(String columnName) { + // 获取当前排序状态 + final currentDirection = state.data.sortStatus[columnName]; + SortDirection newDirection; + + // 确定新的排序方向(两态循环) + if (currentDirection == SortDirection.ascending) { + newDirection = SortDirection.descending; + } else { + newDirection = SortDirection.ascending; + } + + // 排序当前列表 + List sortedHosts = List.from(state.data.hosts); + sortedHosts.sort((a, b) { + int result; + switch (columnName) { + case 'host': + result = a.host.compareTo(b.host); + break; + case 'use': + result = a.isUse == b.isUse ? 0 : (a.isUse ? 1 : -1); + break; + case 'hosts': + result = a.hosts.join(',').compareTo(b.hosts.join(',')); + break; + case 'description': + result = a.description.compareTo(b.description); + break; + default: + result = 0; + } + return newDirection == SortDirection.ascending ? result : -result; + }); + + emit( + HostSort( + state.data.copyWith( + hosts: sortedHosts, + filterHosts: sortedHosts + .where((host) => host.filter(state.data.searchText)) + .toList(), + sortStatus: {columnName: newDirection}, + ), + ), + ); + } + + /// 添加主机项 + /// + /// [hostsModels]: 要添加的主机列表 + void addHosts(List hostsModels) { + final List hosts = List.from(state.data.hosts); + final lines = state.data.fileContent.split("\n"); + + for (var host in hostsModels) { + final newLine = host.toString().split("\n"); + if (newLine.isEmpty) { + continue; + } + if (newLine.length == 2) { + hosts.add( + host.withCopy( + descLine: lines.length, + hostLine: lines.length + 1, + ), + ); + } + if (newLine.length == 1) { + hosts.add( + host.withCopy( + hostLine: lines.length, + ), + ); + } + lines.addAll(newLine); + } + + emit( + HostAdd( + state.data.copyWith( + hosts: hosts, + filterHosts: hosts + .where((host) => host.filter(state.data.searchText)) + .toList(), + isSave: isUpdate(hosts), + fileContent: lines.join("\n"), + ), + ), + ); + } + + /// 撤销操作 + void undoHost() { + emit( + HostUndo( + state.data.copyWith( + hosts: state.data.defaultHosts, + filterHosts: state.data.defaultHosts, + selectHosts: [], + fileContent: state.data.defaultFileContent, + isSave: true, + ), + ), + ); + } + + /// 历史记录变更处理 + /// + /// [history]: 选中的历史记录 + void onHistoryChanged(SimpleHostFileHistory? history) async { + final resultHistory = await _fileManager.getHistory(state.data.fileId); + if (history != null) { + final historyContent = _fileManager.readHistoryFile(history.path); + final hosts = _fileManager.parseHosts(historyContent.split("\n")); + emit( + HostHistory( + state.data.copyWith( + selectHistory: history, + history: resultHistory, + hosts: hosts, + filterHosts: hosts, + isSave: false, + fileContent: historyContent, + ), + ), + ); + + return; + } + + emit( + HostHistory( + state.data.copyWith( + selectHistory: null, + history: resultHistory, + ), + ), + ); + } + + /// 更新文件内容 + /// + /// [text]: 新的文件内容 + void updateFileContent(String text) { + if (text == state.data.defaultFileContent) { + return; + } + + emit( + HostFileContent( + state.data.copyWith( + fileContent: text, + isSave: text == state.data.defaultFileContent, + ), + ), + ); + } + + void fromText(String content) { + final hosts = _fileManager.parseHosts(content.split("\n")); + + emit( + HostInitial( + HostStateData( + fileContent: content, + defaultFileContent: content, + hosts: hosts, + defaultHosts: hosts, + filterHosts: hosts, + ), + ), + ); + } + + /// 检查是否有更新 + /// + /// [hosts]: 当前主机列表 + /// 返回: 是否有更新 + bool isUpdate(List hosts) { + if (hosts.length != state.data.defaultHosts.length) return false; + + for (int i = 0; i < hosts.length; i++) { + if (hosts[i].toString() != state.data.defaultHosts[i].toString()) { + return false; + } + } + + return true; + } + + void onTableSave( + BuildContext context, + HomeStateData homeStateData, + bool isHistory, + ) async { + if (homeStateData.useHostFiles.contains(state.data.fileId)) { + if (!await saveHost( + context, + FileManager.systemHostFilePath, + state.data.fileContent, + )) { + return; + } + } + save(isHistory); + } + + void onTextSave(BuildContext context, String content) async { + final result = await saveHost( + context, + GlobalSettings().filePath ?? FileManager.systemHostFilePath, + state.data.fileContent, + ); + if (GlobalSettings().filePath != null && result) { + fromText(content); + } + } + + void save([bool isHistory = false]) async { + final fileId = state.data.fileId; + final String content = toString(); + final filePath = await _fileManager.getHostsFilePath(fileId); + File(filePath).writeAsStringSync(content); + final List history = []; + if (isHistory) { + await _fileManager.saveHistory(fileId, state.data.defaultFileContent); + history.addAll(await _fileManager.getHistory(fileId)); + } + + emit( + HostSave( + state.data.copyWith( + history: isHistory ? history : state.data.history, + defaultHosts: state.data.hosts, + defaultFileContent: state.data.fileContent, + isSave: true, + ), + ), + ); + } + + Future saveHost( + BuildContext context, String filePath, String hostContent) async { + if (kIsWeb) { + final String tempContent = hostContent.replaceAll("\"", "\\\""); + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.save), + content: SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + child: SelectableText(hostContent), + ), + actions: [ + TextButton( + onPressed: () => writeClipboard( + 'echo "$tempContent" > /etc/hosts', + tempContent, + context, + ), + child: const Text("Linux(echo)")), + TextButton( + onPressed: () { + final String systemHostPath = p.joinAll([ + "C:", + "Windows", + "System32", + "drivers", + "etc", + "hosts" + ]); + final String content = hostContent + .split("\n") + .map((item) => 'echo $item') + .join("\n"); + writeClipboard( + '(\n$content\n) > $systemHostPath', + hostContent, + context, + ); + }, + child: const Text("Windows(echo)")), + TextButton( + onPressed: () => writeClipboard( + 'echo "$tempContent" > /etc/hosts', + tempContent, + context, + ), + child: const Text("MacOS(echo)")), + ], + )); + return true; + } + + final File file = File(filePath); + try { + await file.writeAsString(hostContent); + } catch (e) { + try { + final Directory cacheDirectory = await getApplicationCacheDirectory(); + final File cacheFile = File(p.join(cacheDirectory.path, 'hosts')); + await cacheFile.writeAsString(hostContent); + + await _fileManager.writeFileWithAdminPrivileges( + cacheFile.path, filePath); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.error_save_fail))); + return false; + } + } + + return true; + } + + void writeClipboard( + String hostContent, String defaultContent, BuildContext context) { + Clipboard.setData(ClipboardData(text: hostContent)).then((_) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.copy_to_tip), + ), + ); + }); + } + + /// 转换为字符串 + /// + /// 返回当前文件内容字符串 + @override + String toString() { + return state.data.fileContent; + } + + Future areFilesEqual(String fileId) async { + return await _fileManager.areFilesEqual(fileId); + } + + Future saveFromText(String text) async { + try { + final filePath = await _fileManager.getHostsFilePath(state.data.fileId); + await _fileManager.saveHistory( + state.data.fileId, + File(filePath).readAsStringSync(), + ); + + File(filePath).writeAsStringSync(text); + + return true; + } catch (e) { + return false; + } + } +} diff --git a/lib/home/cubit/host_state.dart b/lib/home/cubit/host_state.dart new file mode 100644 index 0000000..01cd6f5 --- /dev/null +++ b/lib/home/cubit/host_state.dart @@ -0,0 +1,209 @@ +part of 'host_cubit.dart'; + +/// 包含主机文件状态的所有数据 +class HostStateData { + /// 当前文件的唯一标识符 + final String fileId; + + /// 文件内容文本 + final String fileContent; + + /// 默认文件内容文本 + final String defaultFileContent; + + /// 标识文件是否已保存 + final bool isSave; + + /// 过滤后的主机列表 + final List filterHosts; + + /// 当前显示的主机列表 + final List hosts; + + /// 默认主机列表 + final List defaultHosts; + + /// 用户选择的主机列表 + final List selectHosts; + + /// 当前选择的历史记录 + final SimpleHostFileHistory? selectHistory; + + /// 所有历史记录列表 + final List history; + + /// 搜索文本 + final String searchText; + + /// 各列排序状态 + final Map sortStatus; + + /// 创建 HostStateData 实例 + /// + /// [fileId]: 文件ID,默认为空字符串 + /// [fileContent]: 文件内容,默认为空字符串 + /// [isSave]: 是否已保存,默认为true + /// [filterHosts]: 过滤后的主机列表,默认为空列表 + /// [hosts]: 主机列表,默认为空列表 + /// [defaultHosts]: 默认主机列表,默认为空列表 + /// [selectHosts]: 选中的主机列表,默认为空列表 + /// [selectHistory]: 选中的历史记录,默认为null + /// [history]: 历史记录列表,默认为空列表 + /// [searchText]: 搜索文本,默认为空字符串 + HostStateData({ + this.fileId = "", + this.fileContent = "", + this.defaultFileContent = "", + this.isSave = true, + this.filterHosts = const [], + this.hosts = const [], + this.defaultHosts = const [], + this.selectHosts = const [], + this.selectHistory, + this.history = const [], + this.searchText = "", + this.sortStatus = const {}, + }); + + /// 创建当前状态的副本,可选择更新部分字段 + /// + /// 返回一个新的 HostStateData 实例,其中未提供的参数将保留原值 + HostStateData copyWith({ + String? fileId, + String? fileContent, + String? defaultFileContent, + bool? isSave, + List? filterHosts, + List? hosts, + List? defaultHosts, + List? selectHosts, + SimpleHostFileHistory? selectHistory, + List? history, + String? searchText, + Map? sortStatus, + }) { + return HostStateData( + fileId: fileId ?? this.fileId, + fileContent: fileContent ?? this.fileContent, + defaultFileContent: defaultFileContent ?? this.defaultFileContent, + isSave: isSave ?? this.isSave, + filterHosts: filterHosts ?? this.filterHosts, + hosts: hosts ?? this.hosts, + defaultHosts: defaultHosts ?? this.defaultHosts, + selectHosts: selectHosts ?? this.selectHosts, + selectHistory: selectHistory ?? this.selectHistory, + history: history ?? this.history, + searchText: searchText ?? this.searchText, + sortStatus: sortStatus ?? this.sortStatus, + ); + } +} + +/// 主机状态的基类,所有具体状态都必须继承此类 +/// +/// 使用 sealed 修饰确保所有子类都在同一文件中定义 +@immutable +sealed class HostState { + /// 当前状态关联的数据 + final HostStateData data; + + /// 创建 HostState 实例 + const HostState(this.data); +} + +/// 初始状态,表示应用刚启动时的状态 +final class HostInitial extends HostState { + /// 创建初始状态 + const HostInitial(super.data); +} + +/// 切换主机使用状态时的状态 +final class HostToggleUse extends HostState { + /// 创建切换使用状态 + const HostToggleUse(super.data); +} + +/// 编辑主机时的状态 +final class HostEdit extends HostState { + /// 创建编辑状态 + const HostEdit(super.data); +} + +/// 过滤主机时的状态 +final class HostFilter extends HostState { + /// 创建过滤状态 + const HostFilter(super.data); +} + +/// 主机被选中时的状态 +final class HostChecked extends HostState { + /// 创建选中状态 + const HostChecked(super.data); +} + +/// 删除主机时的状态 +final class HostDelete extends HostState { + /// 创建删除状态 + const HostDelete(super.data); +} + +/// 添加主机时的状态 +final class HostAdd extends HostState { + /// 创建添加状态 + const HostAdd(super.data); +} + +/// 撤销操作时的状态 +final class HostUndo extends HostState { + /// 创建撤销状态 + const HostUndo(super.data); +} + +/// 历史记录变更时的状态 +final class HostHistory extends HostState { + /// 创建历史记录状态 + const HostHistory(super.data); +} + +/// 文件内容变更时的状态 +final class HostFileContent extends HostState { + /// 创建文件内容状态 + const HostFileContent(super.data); +} + +/// 保存操作完成时的状态 +/// +/// 当主机文件保存成功后触发此状态 +final class HostSave extends HostState { + /// 创建保存状态 + const HostSave(super.data); +} + +final class HostSort extends HostState { + const HostSort(super.data); +} + +final class HostEditMode extends HostState { + const HostEditMode(super.data); +} + +/// 主机数据加载完成时的状态 +final class HostLoaded extends HostState { + /// 创建加载完成状态 + const HostLoaded(super.data); +} + +/// 正在加载主机数据时的状态 +final class HostLoading extends HostState { + /// 创建加载中状态 + const HostLoading(super.data); +} + +/// 发生错误时的状态 +final class HostError extends HostState { + /// 错误信息 + final String message; + + /// 创建错误状态 + const HostError(super.data, this.message); +} diff --git a/lib/page/history_page.dart b/lib/home/view/history_page.dart similarity index 52% rename from lib/page/history_page.dart rename to lib/home/view/history_page.dart index 90c369a..bb47e77 100644 --- a/lib/page/history_page.dart +++ b/lib/home/view/history_page.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/home/view/hosts_diff_page.dart'; +import 'package:hosts/l10n/app_localizations.dart'; import 'package:hosts/model/simple_host_file.dart'; import 'package:hosts/util/file_manager.dart'; import 'package:hosts/widget/countdown_timer.dart'; - class HistoryPage extends StatefulWidget { + final String fileId; final SimpleHostFileHistory? selectHistory; final List history; @@ -13,6 +14,7 @@ class HistoryPage extends StatefulWidget { super.key, required this.selectHistory, required this.history, + required this.fileId, }); @override @@ -37,7 +39,7 @@ class _HistoryPageState extends State { @override Widget build(BuildContext context) { return Container( - color:Theme.of(context).colorScheme.surfaceContainer, + color: Theme.of(context).colorScheme.surfaceContainer, height: MediaQuery.of(context).size.height, child: Column( mainAxisSize: MainAxisSize.min, @@ -47,7 +49,7 @@ class _HistoryPageState extends State { width: double.maxFinite, padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8), decoration: BoxDecoration( - color:Theme.of(context).colorScheme.surfaceContainer, + color: Theme.of(context).colorScheme.surfaceContainer, ), child: Text( AppLocalizations.of(context)!.history, @@ -90,26 +92,40 @@ class _HistoryPageState extends State { }, )) : null, - trailing: IconButton( - onPressed: () { - setState(() { - if (deleteSimpleHostFileHistory - .contains(hostFile)) { - deleteSimpleHostFileHistory.remove(hostFile); - } else { - deleteSimpleHostFileHistory.add(hostFile); - } - }); - }, - style: OutlinedButton.styleFrom( - minimumSize: Size.zero, - padding: EdgeInsets.zero, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + _showDiffComparison(context, hostFile); + }, + icon: const Icon(Icons.compare_arrows), + tooltip: AppLocalizations.of(context)!.view_diff, + ), + IconButton( + onPressed: () { + setState(() { + if (deleteSimpleHostFileHistory + .contains(hostFile)) { + deleteSimpleHostFileHistory.remove(hostFile); + } else { + deleteSimpleHostFileHistory.add(hostFile); + } + }); + }, + style: OutlinedButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.zero, + ), + icon: Icon( + deleteSimpleHostFileHistory.contains(hostFile) + ? Icons.close + : Icons.delete_outline, + ), + tooltip: AppLocalizations.of(context)!.delete, ), - icon: Icon( - deleteSimpleHostFileHistory.contains(hostFile) - ? Icons.close - : Icons.delete_outline, - )), + ], + ), selectedTileColor: Theme.of(context).colorScheme.primaryContainer, onTap: () { @@ -125,4 +141,52 @@ class _HistoryPageState extends State { ), ); } + + void _showDiffComparison( + BuildContext context, SimpleHostFileHistory hostFile) async { + try { + final fileManager = FileManager(); + + // 读取历史文件内容 + final historyContent = fileManager.readHistoryFile(hostFile.path); + + // 获取当前文件内容 + final currentContent = await fileManager.readAsString(widget.fileId); + + // 格式化时间标签 + final dateTime = + DateTime.fromMillisecondsSinceEpoch(int.parse(hostFile.fileName)); + final year = dateTime.year.toString(); + final month = dateTime.month.toString().padLeft(2, '0'); + final day = dateTime.day.toString().padLeft(2, '0'); + final hour = dateTime.hour.toString().padLeft(2, '0'); + final minute = dateTime.minute.toString().padLeft(2, '0'); + final second = dateTime.second.toString().padLeft(2, '0'); + + final historyLabel = + '${AppLocalizations.of(context)!.history_version}: $year-$month-$day $hour:$minute:$second'; + final currentLabel = AppLocalizations.of(context)!.current_version; + + // 导航到差异对比页面 + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => HostsDiffPage( + historyContent: historyContent, + currentContent: currentContent, + historyLabel: historyLabel, + currentLabel: currentLabel, + ), + ), + ); + } catch (e) { + // 显示错误信息 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${AppLocalizations.of(context)!.unable_to_read_history_file}: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } } diff --git a/lib/home/view/home_app_bar.dart b/lib/home/view/home_app_bar.dart new file mode 100644 index 0000000..c68c013 --- /dev/null +++ b/lib/home/view/home_app_bar.dart @@ -0,0 +1,560 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hosts/enums.dart'; +import 'package:hosts/home/cubit/home_cubit.dart'; +import 'package:hosts/home/cubit/host_cubit.dart'; +import 'package:hosts/home/view/history_page.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/global_settings.dart'; +import 'package:hosts/model/host_file.dart'; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/widget/dialog/copy_multiple_dialog.dart'; +import 'package:hosts/widget/snakbar.dart'; +import 'package:hosts/widget/text_field/search_text_field.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 首页应用栏组件 +/// +/// 包含文件操作、搜索、历史记录等功能的顶部工具栏 +class HomeAppBar extends StatelessWidget { + /// 构造函数 + const HomeAppBar({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final homeCubit = context.read(); + final homeStateData = state.data; + return Column( + children: [ + LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 600; + return Container( + height: isNarrow ? null : 58, + padding: const EdgeInsets.symmetric(horizontal: 5), + child: isNarrow + ? _buildNarrowLayout(context, homeCubit, homeStateData) + : _buildWideLayout(context, homeCubit, homeStateData), + ); + }, + ) + ], + ); + }, + ); + } + + IconButton _buildEditModeButton(HomeCubit homeCubit, BuildContext context) { + return IconButton( + onPressed: () { + if (homeCubit.state.data.editMode == EditMode.Text) { + homeCubit.toggleEditMode(EditMode.Table); + } else { + homeCubit.toggleEditMode(EditMode.Text); + } + }, + tooltip: homeCubit.state.data.editMode == EditMode.Text + ? AppLocalizations.of(context)!.table + : AppLocalizations.of(context)!.text, + icon: Icon( + homeCubit.state.data.editMode == EditMode.Text + ? Icons.table_rows_outlined + : Icons.text_snippet_outlined, + ), + ); + } + + Widget batchGroupButton(HomeCubit homeCubit) { + return BlocBuilder( + builder: (BuildContext context, state) { + final selectHosts = state.data.selectHosts; + final hostCubit = context.read(); + return Row( + children: [ + if (selectHosts.isNotEmpty && + homeCubit.state.data.editMode == EditMode.Table) + Switch( + value: true, + onChanged: (value) { + final Map hostsMap = {}; + for (var host in selectHosts) { + hostsMap[host] = host.withCopy(isUse: true); + } + hostCubit.onToggleUse(hostsMap); + }, + ), + if (selectHosts.isNotEmpty && + homeCubit.state.data.editMode == EditMode.Table) + Switch( + value: false, + onChanged: (value) { + final Map hostsMap = {}; + for (var host in selectHosts) { + hostsMap[host] = host.withCopy(isUse: false); + } + hostCubit.onToggleUse(hostsMap); + }, + ), + if (selectHosts.isNotEmpty && + homeCubit.state.data.editMode == EditMode.Table) + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => + CopyMultipleDialog(hosts: selectHosts)); + }, + tooltip: AppLocalizations.of(context)!.copy_selected, + icon: const Icon(Icons.copy)), + if (selectHosts.isNotEmpty && + homeCubit.state.data.editMode == EditMode.Table) + IconButton( + onPressed: () { + deleteMultiple( + context, selectHosts.map((it) => it.host).toList(), () { + hostCubit.onDelete(selectHosts); + }); + }, + tooltip: AppLocalizations.of(context)!.delete_selected, + icon: const Icon(Icons.delete_outline)), + ], + ); + }, + ); + } + + void pickFile(BuildContext context, FilePickerResult result) { + if (result.files.first.size > 10 * 1024 * 1024) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.error_open_file_size))); + return; + } + + try { + String path = ""; + try { + path = result.files.single.path ?? ""; + } catch (e) { + path = ""; + } + final Uint8List? bytes = result.files.first.bytes; + if (path.isNotEmpty && bytes == null) { + context.read().fromText(File(path).readAsStringSync()); + } + + GlobalSettings().filePath = path; + + if (bytes != null) { + context.read().fromText(utf8.decode(bytes)); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.error_open_file))); + } + } + + Widget buildMoreButton(BuildContext context) { + return PopupMenuButton(onSelected: (value) async { + switch (value) { + case 1: + // 检查更新 + await _launchUrl(context, 'https://github.com/webb-l/hosts/releases'); + break; + case 2: + // 反馈问题 + await _launchUrl(context, 'https://github.com/webb-l/hosts/issues'); + break; + case 3: + // 关于 + final packageInfo = await PackageInfo.fromPlatform(); + + if (context.mounted) { + showAboutDialog( + context: context, + applicationVersion: packageInfo.version, + applicationIcon: Image.asset( + "assets/icon/logo.png", + width: 50, + height: 50, + ), + children: [ + Text(AppLocalizations.of(context)!.about_description), + const SizedBox(height: 10), + const Text('Developed by Webb.'), + ], + ); + } + break; + default: + break; + } + }, itemBuilder: (BuildContext context) { + final List> list = [ + { + "text": AppLocalizations.of(context)!.check_for_updates, + "value": 1, + "icon": Icons.system_update + }, + { + "text": AppLocalizations.of(context)!.report_issue, + "value": 2, + "icon": Icons.bug_report + }, + { + "text": AppLocalizations.of(context)!.about, + "value": 3, + "icon": Icons.info + }, + ]; + + return list.map((item) { + return PopupMenuItem( + value: int.parse(item["value"].toString()), + child: Row( + children: [ + if (item["icon"] != null) Icon(item["icon"]! as IconData), + SizedBox(width: item["icon"] != null ? 8 : 0), + Text(item["text"]!.toString()), + ], + ), + ); + }).toList(); + }); + } + + /// 打开URL + Future _launchUrl(BuildContext context, String url) async { + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(AppLocalizations.of(context)!.unable_to_open(url))), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${AppLocalizations.of(context)!.unable_to_open(url)}: $e')), + ); + } + } + } + + Widget _buildWideLayout( + BuildContext context, HomeCubit homeCubit, HomeStateData homeStateData) { + return Row( + children: [ + Expanded( + child: Row( + children: [ + if (GlobalSettings().filePath != null) + IconButton( + onPressed: () async { + FilePickerResult? result = + await FilePicker.platform.pickFiles(); + if (result == null) return; + if (!context.read().state.data.isSave) { + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context)!.error_not_save), + action: SnackBarAction( + label: AppLocalizations.of(context)!.abort, + onPressed: () => pickFile(context, result), + ), + )); + return; + } + pickFile(context, result); + }, + icon: const Icon(Icons.file_open_outlined), + tooltip: AppLocalizations.of(context)!.open_file, + ) + else + IconButton( + onPressed: homeCubit.toggleAdvancedSettingsSwitch, + icon: const Icon(Icons.menu), + tooltip: AppLocalizations.of(context)!.advanced_settings, + ), + _buildEditModeButton(homeCubit, context), + const SizedBox(width: 10), + if (homeStateData.editMode == EditMode.Table) + Flexible( + child: Container( + constraints: const BoxConstraints( + maxWidth: 430, + minWidth: 100, + ), + child: BlocBuilder( + builder: (context, state) { + return SearchTextField( + text: state.data.searchText, + onChanged: context.read().updateSearchText, + ); + }, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 32), + BlocBuilder( + builder: (context, state) { + final hostStateData = state.data; + final hostCubit = context.read(); + return Row( + children: [ + batchGroupButton(homeCubit), + if (hostStateData.history.isNotEmpty) + IconButton( + onPressed: () async { + SimpleHostFileHistory? resultHistory = + await showModalBottomSheet( + context: context, + builder: (BuildContext context) => HistoryPage( + selectHistory: hostStateData.selectHistory, + history: hostStateData.history, + fileId: state.data.fileId, + ), + ); + if (resultHistory == null) { + hostCubit.onHistoryChanged(null); + return; + } + if (!hostStateData.isSave) { + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + AppLocalizations.of(context)!.error_not_save), + action: SnackBarAction( + label: AppLocalizations.of(context)!.abort, + onPressed: () => + hostCubit.onHistoryChanged(resultHistory), + ), + )); + return; + } + hostCubit.onHistoryChanged(resultHistory); + }, + icon: const Icon(Icons.history), + ), + if (!hostStateData.isSave) + IconButton( + onPressed: context.read().undoHost, + icon: const Icon(Icons.undo), + tooltip: AppLocalizations.of(context)!.reduction, + ), + buildMoreButton(context) + ], + ); + }, + ) + ], + ); + } + + Widget _buildNarrowLayout( + BuildContext context, HomeCubit homeCubit, HomeStateData homeStateData) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + if (GlobalSettings().filePath != null) + IconButton( + onPressed: () async { + FilePickerResult? result = + await FilePicker.platform.pickFiles(); + if (result == null) return; + if (!context.read().state.data.isSave) { + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context)!.error_not_save), + action: SnackBarAction( + label: AppLocalizations.of(context)!.abort, + onPressed: () => pickFile(context, result), + ), + )); + return; + } + pickFile(context, result); + }, + icon: const Icon(Icons.file_open_outlined), + tooltip: AppLocalizations.of(context)!.open_file, + ) + else + IconButton( + onPressed: homeCubit.toggleAdvancedSettingsSwitch, + icon: const Icon(Icons.menu), + tooltip: AppLocalizations.of(context)!.advanced_settings, + ), + _buildEditModeButton(homeCubit, context), + const Spacer(), + BlocBuilder( + builder: (context, state) { + final hostStateData = state.data; + final hostCubit = context.read(); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: _buildNarrowBatchActions(homeCubit), + ), + if (hostStateData.history.isNotEmpty) + IconButton( + onPressed: () async { + SimpleHostFileHistory? resultHistory = + await showModalBottomSheet( + context: context, + builder: (BuildContext context) => HistoryPage( + selectHistory: hostStateData.selectHistory, + history: hostStateData.history, + fileId: state.data.fileId, + ), + ); + if (resultHistory == null) { + hostCubit.onHistoryChanged(null); + return; + } + if (!hostStateData.isSave) { + ScaffoldMessenger.of(context) + .removeCurrentSnackBar(); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)! + .error_not_save), + action: SnackBarAction( + label: AppLocalizations.of(context)!.abort, + onPressed: () => + hostCubit.onHistoryChanged(resultHistory), + ), + )); + return; + } + hostCubit.onHistoryChanged(resultHistory); + }, + icon: const Icon(Icons.history), + tooltip: AppLocalizations.of(context)!.history, + ), + if (!hostStateData.isSave) + IconButton( + onPressed: context.read().undoHost, + icon: const Icon(Icons.undo), + tooltip: AppLocalizations.of(context)!.reduction, + ), + buildMoreButton(context) + ], + ); + }, + ), + ], + ), + ), + if (homeStateData.editMode == EditMode.Table) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: BlocBuilder( + builder: (context, state) { + return SearchTextField( + text: state.data.searchText, + onChanged: context.read().updateSearchText, + ); + }, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildNarrowBatchActions(HomeCubit homeCubit) { + return BlocBuilder( + builder: (BuildContext context, state) { + final selectHosts = state.data.selectHosts; + final hostCubit = context.read(); + + if (selectHosts.isEmpty || + homeCubit.state.data.editMode != EditMode.Table) { + return const SizedBox.shrink(); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Switch( + value: true, + onChanged: (value) { + final Map hostsMap = {}; + for (var host in selectHosts) { + hostsMap[host] = host.withCopy(isUse: true); + } + hostCubit.onToggleUse(hostsMap); + }, + ), + Switch( + value: false, + onChanged: (value) { + final Map hostsMap = {}; + for (var host in selectHosts) { + hostsMap[host] = host.withCopy(isUse: false); + } + hostCubit.onToggleUse(hostsMap); + }, + ), + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => + CopyMultipleDialog(hosts: selectHosts), + ); + }, + tooltip: AppLocalizations.of(context)!.copy_selected, + icon: const Icon(Icons.copy), + ), + IconButton( + onPressed: () { + deleteMultiple( + context, + selectHosts.map((it) => it.host).toList(), + () { + hostCubit.onDelete(selectHosts); + }, + ); + }, + tooltip: AppLocalizations.of(context)!.delete_selected, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/home/view/home_drawer.dart b/lib/home/view/home_drawer.dart new file mode 100644 index 0000000..90785bd --- /dev/null +++ b/lib/home/view/home_drawer.dart @@ -0,0 +1,343 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hosts/home/cubit/home_cubit.dart'; +import 'package:hosts/home/cubit/host_cubit.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/server/view/server_settings_page.dart'; +import 'package:hosts/util/file_manager.dart'; +import 'package:hosts/widget/dialog/dialog.dart'; +import 'package:hosts/widget/dialog/export_hosts_dialog.dart'; +import 'package:hosts/widget/dialog/import_hosts_dialog.dart'; +import 'package:hosts/widget/snakbar.dart'; + +class HomeDrawer extends StatelessWidget { + const HomeDrawer({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Drawer( + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Text( + AppLocalizations.of(context)!.app_name, + style: Theme.of(context).textTheme.titleLarge, + ), + Spacer(), + IconButton( + onPressed: () async { + String? remark = await hostConfigDialog(context); + if (remark == null || remark.isEmpty) return; + context.read().addHostFile(remark); + }, + icon: const Icon(Icons.add), + tooltip: AppLocalizations.of(context)!.add, + ), + _buildOptionsMenu(context, state), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: state.data.hostFiles.length, + padding: EdgeInsets.zero, + itemBuilder: (context, index) { + final hostFile = state.data.hostFiles[index]; + return ListTile( + title: Text(hostFile.remark), + leading: IconButton( + tooltip: AppLocalizations.of(context)!.use, + style: OutlinedButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.zero, + ), + onPressed: state.data.useHostFiles + .contains(hostFile.fileName) + ? null + : () async { + final result = await context + .read() + .useHost(hostFile.fileName); + if (!result) { + // 使用SnackBar提示错误 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)! + .error_use_fail), + ), + ); + } + }, + icon: Icon(state.data.useHostFiles + .contains(hostFile.fileName) + ? Icons.star + : Icons.star_border), + ), + selectedTileColor: + Theme.of(context).colorScheme.primaryContainer, + selected: + state.data.selectHostFile == hostFile.fileName, + trailing: buildMoreButton(hostFile), + onTap: () async { + if (state.data.selectHostFile == hostFile.fileName) { + return; + } + + if (!context.read().state.data.isSave) { + ScaffoldMessenger.of(context) + .removeCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)! + .error_not_save), + action: SnackBarAction( + label: AppLocalizations.of(context)!.abort, + onPressed: () { + context + .read() + .selectHost(hostFile.fileName); + }, + ), + ), + ); + return; + } + + context + .read() + .selectHost(hostFile.fileName); + + if (state.data.useHostFiles + .contains(hostFile.fileName)) { + if (!await context + .read() + .areFilesEqual(hostFile.fileName)) { + final homeCubit = context.read(); + final hostCubit = context.read(); + await showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text( + AppLocalizations.of(dialogContext)! + .warning), + content: Text( + AppLocalizations.of(dialogContext)! + .warning_different), + actions: [ + TextButton( + onPressed: () async { + final result = await homeCubit + .useHost(hostFile.fileName); + if (!result) { + // 使用SnackBar提示错误 + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context)! + .error_use_fail), + ), + ); + return; + } + + Navigator.of(dialogContext).pop(); + }, + child: Text(AppLocalizations.of( + dialogContext)! + .warning_different_covering_system), + ), + TextButton( + onPressed: () async { + await hostCubit.saveFromText(File( + FileManager + .systemHostFilePath) + .readAsStringSync()); + await homeCubit + .selectHost(hostFile.fileName); + Navigator.of(dialogContext).pop(); + }, + child: Text(AppLocalizations.of( + dialogContext)! + .warning_different_covering_current), + ), + ], + ); + }); + } + } + }, + ); + }, + ), + ) + ], + ), + ), + ); + }, + ); + } + + Widget _buildOptionsMenu(BuildContext context, HomeState state) { + return PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) async { + switch (value) { + case 1: + // Import functionality + await importHostsDialog(context, state.data.hostFiles, + onImportSuccess: () { + context.read().refreshHostFiles(context); + }); + break; + case 2: + // Export functionality + await exportHostsDialog(context, state.data.hostFiles); + break; + case 3: + // Remote sync functionality + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ServerSettingsPage(), + ), + ); + + context.read().refreshHostFiles(context); + break; + } + }, + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + value: 1, + child: Row( + children: [ + const Icon(Icons.file_upload), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.import), + ], + ), + ), + PopupMenuItem( + value: 2, + child: Row( + children: [ + const Icon(Icons.file_download), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.export), + ], + ), + ), + PopupMenuItem( + value: 3, + child: Row( + children: [ + const Icon(Icons.cloud_sync), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.remote_sync), + ], + ), + ), + ]; + }, + ); + } + + Widget buildMoreButton(SimpleHostFile hostFile) { + if (hostFile.fileName == "system") { + return const SizedBox(); + } + + return BlocBuilder( + builder: (context, state) { + return _buildHostFileOptionsMenu(context, hostFile); + }, + ); + } + + Widget _buildHostFileOptionsMenu( + BuildContext context, SimpleHostFile hostFile) { + final homeCubit = context.read(); + + return PopupMenuButton( + style: OutlinedButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.zero, + ), + onSelected: (value) async { + switch (value) { + case 1: + String result = + (await hostConfigDialog(context, hostFile.remark) ?? ""); + if (result.isEmpty) return; + homeCubit.updateHostFileRemark(hostFile.fileName, result); + break; + case 2: + deleteMultiple(context, [hostFile.remark], () async { + homeCubit.deleteHostFile(hostFile.fileName); + }); + break; + case 3: + final FileManager fileManager = FileManager(); + final bool success = await fileManager.exportMultipleHostFiles( + [hostFile], AppLocalizations.of(context)!.export_data); + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(AppLocalizations.of(context)!.export_success)), + ); + } + break; + } + }, + itemBuilder: (BuildContext context) { + List> list = [ + { + "icon": Icons.edit, + "text": AppLocalizations.of(context)!.edit, + "value": 1 + }, + { + "icon": Icons.file_download, + "text": AppLocalizations.of(context)!.export, + "value": 3 + }, + { + "icon": Icons.delete_outline, + "text": AppLocalizations.of(context)!.remove, + "value": 2 + }, + ]; + + return list.map((item) { + return PopupMenuItem( + value: int.parse(item["value"].toString()), + child: Row( + children: [ + Icon(item["icon"]! as IconData), + const SizedBox(width: 8), + Text(item["text"]!.toString()), + ], + ), + ); + }).toList(); + }, + ); + } +} diff --git a/lib/home/view/home_page.dart b/lib/home/view/home_page.dart new file mode 100644 index 0000000..7416ef7 --- /dev/null +++ b/lib/home/view/home_page.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hosts/home/cubit/home_cubit.dart'; +import 'package:hosts/home/cubit/host_cubit.dart'; +import 'package:hosts/home/view/home_view.dart'; +import 'package:hosts/home/view/simple_home_view.dart'; +import 'package:hosts/server/bloc/nearby_devices_cubit.dart'; + +/// 首页页面组件 +/// +/// 负责初始化和管理首页相关的Cubit状态 +/// 包含HomeCubit和HostCubit的初始化 +class HomePage extends StatelessWidget { + /// 构造函数 + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => HomeCubit()), + BlocProvider(create: (context) => HostCubit()), + BlocProvider(create: (context) => NearbyDevicesCubit()), + ], + child: const HomeView(), + ); + } +} + +class SimpleHomePage extends StatelessWidget { + const SimpleHomePage({super.key}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => HomeCubit()), + BlocProvider(create: (context) => HostCubit()), + ], + child: SimpleHomeView(), + ); + } +} diff --git a/lib/home/view/home_view.dart b/lib/home/view/home_view.dart new file mode 100644 index 0000000..5fc5904 --- /dev/null +++ b/lib/home/view/home_view.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hosts/enums.dart'; +import 'package:hosts/home/cubit/home_cubit.dart'; +import 'package:hosts/home/cubit/host_cubit.dart'; +import 'package:hosts/home/view/home_app_bar.dart'; +import 'package:hosts/home/view/home_drawer.dart'; +import 'package:hosts/home/view/host_page.dart'; +import 'package:hosts/home/view/host_view.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/host_file.dart'; + +class HomeView extends StatefulWidget { + const HomeView({super.key}); + + @override + State createState() => _HomeViewState(); +} + +class _HomeViewState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + @override + void initState() { + // 初始化时加载 hostFiles + context.read().loadHostFiles(context, true); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + drawer: + MediaQuery.of(context).size.width < 600 ? const HomeDrawer() : null, + onDrawerChanged: (value) { + if (!value) { + context.read().toggleAdvancedSettingsSwitch(); + } + }, + floatingActionButton: BlocBuilder( + builder: (context, state) { + if (state.data.editMode == EditMode.Table) { + return FloatingActionButton( + onPressed: () async { + List? hostsModels = await Navigator.of(context) + .push(MaterialPageRoute( + builder: (context) => const HostPage())); + if (hostsModels == null) return; + context.read().addHosts(hostsModels); + }, + child: const Icon(Icons.add), + ); + } + return const SizedBox(); + }, + ), + body: SafeArea( + child: BlocBuilder( + builder: (context, state) { + if (state is HomeSelectHostFileChanged || state is HomeDelete) { + context.read().updateHost(state.data.selectHostFile); + } + + if (state is HomeEditMode) { + context.read().updateEditMode(state.data.editMode); + } + + if (state is HomeAdvancedSettings) { + if (state.data.advancedSettingsEnum == + AdvancedSettingsEnum.Open) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scaffoldKey.currentState?.openDrawer(); + }); + } + } + + return Row( + children: [ + if (state.data.advancedSettingsEnum == + AdvancedSettingsEnum.Close && + MediaQuery.of(context).size.width > 600) + const HomeDrawer(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const HomeAppBar(), + saveTipMessage(state.data), + HostView(state.data) + ], + )) + ], + ); + }, + ), + ), + ); + } + + Widget saveTipMessage(HomeStateData data) { + return BlocBuilder(builder: (context, state) { + final hostCubit = context.read(); + if (state.data.isSave) { + return const SizedBox(); + } + + final homeCubit = context.read(); + + final String updateSaveTip = + AppLocalizations.of(context)!.error_not_update_save_tip; + final String updateSavePermissionTip = data.selectHostFile == + state.data.fileId + ? '\n${AppLocalizations.of(context)!.error_not_update_save_permission_tip}' + : ''; + return MaterialBanner( + content: Text("$updateSaveTip$updateSavePermissionTip"), + leading: const Icon(Icons.error_outline), + actions: [ + TextButton( + onPressed: () => + hostCubit.onTableSave(context, homeCubit.state.data, true), + child: Text(AppLocalizations.of(context)!.save_create_history), + ), + TextButton( + onPressed: () => + hostCubit.onTableSave(context, homeCubit.state.data, false), + child: Text(AppLocalizations.of(context)!.save), + ), + ], + ); + }); + } +} diff --git a/lib/home/view/host_list.dart b/lib/home/view/host_list.dart new file mode 100644 index 0000000..9b3880a --- /dev/null +++ b/lib/home/view/host_list.dart @@ -0,0 +1,389 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hosts/enums.dart'; +import 'package:hosts/home/cubit/host_cubit.dart'; +import 'package:hosts/home/view/host_page.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/host_file.dart'; +import 'package:hosts/widget/dialog/copy_dialog.dart'; +import 'package:hosts/widget/dialog/link_dialog.dart'; +import 'package:hosts/widget/dialog/test_dialog.dart'; +import 'package:hosts/widget/snakbar.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 主机列表组件 +/// +/// 显示主机列表并提供交互功能,包括: +/// - 主机项的增删改查 +/// - 状态切换 +/// - 排序功能 +/// - 批量操作 +class HostList extends StatelessWidget { + /// 构造函数 + const HostList({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, state) { + final hostCubit = context.read(); + final hostStateData = state.data; + return Column( + children: [ + // 表头行 + Row( + children: [ + Container( + width: 50, + margin: EdgeInsets.symmetric(horizontal: 4), + child: Checkbox( + value: state.data.selectHosts.isNotEmpty && + state.data.selectHosts.length == + state.data.filterHosts.length, + onChanged: context.read().updateCheckedAll, + ), + ), + HeaderColumn( + columnName: 'host', + text: AppLocalizations.of(context)!.ip_address, + cubit: hostCubit, + ), + SizedBox(width: 16), + HeaderColumn( + columnName: 'use', + text: AppLocalizations.of(context)!.status, + cubit: hostCubit, + ), + SizedBox(width: 16), + HeaderColumn( + columnName: 'hosts', + text: AppLocalizations.of(context)!.domain, + cubit: hostCubit, + ), + SizedBox(width: 16), + HeaderColumn( + columnName: 'description', + text: AppLocalizations.of(context)!.remark, + cubit: hostCubit, + ), + ], + ), + Expanded( + child: ListView.builder( + itemCount: hostStateData.filterHosts.length, + itemBuilder: (context, index) { + final HostsModel host = hostStateData.filterHosts[index]; + return InkWell( + onTap: () async { + List? hostsModels = + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => HostPage(hostModel: host), + ), + ); + if (hostsModels == null) return; + hostCubit.onEdit(index, hostsModels.first); + }, + child: ListItem( + host: host, + onSwitchChanged: (value) { + // Map<旧, 新> + final Map updateUseHosts = { + host: host.withCopy(isUse: value) + }; + void updateHostStates( + List hostNames, bool isUse) { + for (var tempHost in hostStateData.filterHosts + .where((item) => hostNames.contains(item.host))) { + updateUseHosts[tempHost] = + tempHost.withCopy(isUse: isUse); + } + } + + if (host.config["same"] != null) { + updateHostStates( + (host.config["same"] as List) + .cast(), + value); + } + if (host.config["contrary"] != null) { + updateHostStates( + (host.config["contrary"] as List) + .cast(), + !value); + } + + hostCubit.onToggleUse(updateUseHosts); + }, + onCheckChanged: (value) => + hostCubit.onChecked(index, host), + trailing: buildMoreButton( + context, hostCubit, hostStateData, index, host), + isChecked: hostStateData.selectHosts.contains(host), + ), + ); + }, + ), + ) + ], + ); + }, + ); + } + + Widget buildMoreButton(BuildContext context, HostCubit hostCubit, + HostStateData hostStateData, int index, HostsModel host) { + return PopupMenuButton( + onSelected: (value) async { + switch (value) { + case 1: + final Map>? result = + await linkDialog(context, hostStateData.filterHosts, host); + if (result == null) return; + hostCubit.onEdit(index, host.withCopy(config: result)); + break; + case 2: + testDialog(context, host); + break; + case 3: + copyDialog(context, hostStateData.filterHosts, index); + break; + case 4: + deleteMultiple( + context, + hostStateData.filterHosts.map((it) => it.host).toList(), + () => hostCubit.onDelete([host])); + break; + } + }, + itemBuilder: (BuildContext context) { + List> list = [ + { + "icon": Icons.link, + "text": AppLocalizations.of(context)!.link, + "value": 1 + }, + { + "icon": Icons.sensors, + "text": AppLocalizations.of(context)!.test, + "value": 2 + }, + { + "icon": Icons.copy, + "text": AppLocalizations.of(context)!.copy, + "value": 3 + }, + { + "icon": Icons.delete_outline, + "text": AppLocalizations.of(context)!.delete, + "value": 4 + }, + ]; + + return list + .where((item) => !(item["value"] == 2 && kIsWeb)) + .map((item) { + return PopupMenuItem( + value: int.parse(item["value"].toString()), + child: Row( + children: [ + Icon(item["icon"]! as IconData), + const SizedBox(width: 8), + Text(item["text"]!.toString()), + ], + ), + ); + }).toList(); + }, + ); + } +} + +/// 主机列表项组件 +/// +/// 显示单个主机项的详细信息,包括: +/// - 复选框状态 +/// - 开关状态 +/// - 主机信息 +/// - 操作按钮 +class ListItem extends StatelessWidget { + /// 是否选中 + final bool isChecked; + + /// 主机数据模型 + final HostsModel host; + + /// 开关状态变更回调 + final ValueChanged onSwitchChanged; + + /// 复选框状态变更回调 + final ValueChanged onCheckChanged; + + /// 右侧操作按钮组件 + final Widget trailing; + + /// 构造函数 + const ListItem( + {super.key, + required this.host, + required this.onSwitchChanged, + required this.onCheckChanged, + required this.isChecked, + required this.trailing}); + + @override + Widget build(BuildContext context) { + bool isLink = false; + if (host.config.isNotEmpty) { + isLink = host.config["same"] != null && host.config["contrary"] != null; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 9), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Checkbox( + value: isChecked, + onChanged: onCheckChanged, + ), + const SizedBox(width: 16), + Switch( + value: host.isUse, + onChanged: onSwitchChanged, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (host.description.isNotEmpty) + Text( + host.description, + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(height: 4.0), + Text.rich(TextSpan( + children: [ + if (isLink) + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + Icons.link, + color: Theme.of(context).colorScheme.primary, + size: 18, + ), + )), + TextSpan( + text: host.host, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold), + ) + ], + )), + const SizedBox(height: 4.0), + Text.rich(TextSpan( + children: _buildTextSpans(host.hosts, context), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.6), + fontWeight: FontWeight.bold))), + ], + ), + ), + const SizedBox(width: 16), + trailing, + ], + ), + ); + } + + List _buildTextSpans(List hosts, BuildContext context) { + List textSpans = []; + + for (int i = 0; i < hosts.length; i++) { + textSpans.add(TextSpan( + text: hosts[i], + recognizer: TapGestureRecognizer() + ..onTap = () async { + final url = Uri.parse('http://${hosts[i]}'); + if (await canLaunchUrl(url)) { + await launchUrl(url); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.unable_to_open(hosts[i]))), + ); + } + } + }, + )); + + if (i < hosts.length - 1) { + textSpans.add(TextSpan( + text: ' - ', + style: TextStyle( + color: Theme.of(context).colorScheme.inverseSurface, + fontWeight: FontWeight.w900))); + } + } + + return textSpans; + } +} + +class HeaderColumn extends StatelessWidget { + final String columnName; + final String text; + final HostCubit cubit; + + const HeaderColumn({ + super.key, + required this.columnName, + required this.text, + required this.cubit, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + onTap: () => cubit.onSort(columnName), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + text, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + BlocBuilder( + builder: (context, state) { + final direction = state.data.sortStatus[columnName]; + return Icon( + direction == SortDirection.ascending + ? Icons.arrow_upward + : direction == SortDirection.descending + ? Icons.arrow_downward + : Icons.unfold_more, + size: 16, + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/page/host_page.dart b/lib/home/view/host_page.dart similarity index 99% rename from lib/page/host_page.dart rename to lib/home/view/host_page.dart index 66b4256..ce7a43b 100644 --- a/lib/page/host_page.dart +++ b/lib/home/view/host_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; import 'package:hosts/enums.dart'; import 'package:hosts/model/host_file.dart'; import 'package:hosts/util/regexp_util.dart'; @@ -208,7 +208,7 @@ class _HostPageState extends State { icon: const Icon(Icons.chevron_right), tooltip: AppLocalizations.of(context)!.next, ), - const SizedBox(width: 16), + const SizedBox(width: 8), IconButton( onPressed: hosts.length == 1 ? null diff --git a/lib/home/view/host_table.dart b/lib/home/view/host_table.dart new file mode 100644 index 0000000..61154d7 --- /dev/null +++ b/lib/home/view/host_table.dart @@ -0,0 +1,471 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hosts/home/cubit/host_cubit.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/host_file.dart'; +import 'package:hosts/widget/dialog/copy_dialog.dart'; +import 'package:hosts/widget/dialog/link_dialog.dart'; +import 'package:hosts/widget/dialog/test_dialog.dart'; +import 'package:hosts/widget/snakbar.dart'; +import 'package:syncfusion_flutter_datagrid/datagrid.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'host_page.dart'; + +/// 主机表格组件 +/// +/// 使用SfDataGrid显示主机数据表格,提供: +/// - 排序功能 +/// - 分页显示 +/// - 交互操作 +class HostTable extends StatelessWidget { + /// 构造函数 + const HostTable({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final hostCubit = context.read(); + + return SfDataGrid( + allowSorting: true, + columnWidthMode: ColumnWidthMode.fill, + gridLinesVisibility: GridLinesVisibility.none, + headerGridLinesVisibility: GridLinesVisibility.none, + source: HostDataSource( + hosts: state.data.filterHosts, + selectHosts: state.data.selectHosts, + onEdit: (index, host) async { + List? hostsModels = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => HostPage(hostModel: host), + ), + ); + if (hostsModels == null) return; + hostCubit.onEdit(index, hostsModels.first); + }, + onLink: (index, host) async { + final Map>? result = + await linkDialog(context, state.data.hosts, host); + if (result == null) return; + hostCubit.onEdit(index, host.withCopy(config: result)); + }, + onChecked: hostCubit.onChecked, + onDelete: (hosts) { + deleteMultiple(context, hosts.map((it) => it.host).toList(), + () => hostCubit.onDelete(hosts)); + }, + onToggleUse: hostCubit.onToggleUse, + onLaunchUrl: (host) async { + final url = Uri.parse('http://$host'); + if (await canLaunchUrl(url)) { + await launchUrl(url); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.unable_to_open(host))), + ); + } + } + }, + context: context, + ), + columns: [ + GridColumn( + columnName: 'checkbox', + allowSorting: false, + label: Checkbox( + value: state.data.selectHosts.isNotEmpty && + state.data.selectHosts.length == state.data.filterHosts.length, + onChanged: context.read().updateCheckedAll, + ), + width: 50, + ), + GridColumn( + columnName: 'host', + allowSorting: true, + label: Container( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(AppLocalizations.of(context)!.ip_address, + style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ), + GridColumn( + columnName: 'use', + allowSorting: true, + label: Container( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(AppLocalizations.of(context)!.status, + style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ), + GridColumn( + columnName: 'hosts', + allowSorting: true, + label: Container( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(AppLocalizations.of(context)!.domain, + style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ), + GridColumn( + columnName: 'description', + allowSorting: true, + label: Container( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(AppLocalizations.of(context)!.remark, + style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ), + GridColumn( + columnName: 'actions', + allowSorting: false, + label: Container( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(AppLocalizations.of(context)!.action, + style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ), + ], + ); + }, + ); + } +} + +/// 主机表格数据源 +/// +/// 负责管理表格数据并提供排序功能 +class HostDataSource extends DataGridSource { + /// 构造函数 + /// + /// [hosts]: 主机数据列表 + /// [selectHosts]: 已选中的主机列表 + /// [onEdit]: 编辑回调函数 + /// [onLink]: 链接回调函数 + /// [onChecked]: 选中状态变更回调 + /// [onDelete]: 删除回调函数 + /// [onToggleUse]: 使用状态切换回调 + /// [onLaunchUrl]: URL跳转回调 + /// [context]: 构建上下文 + HostDataSource({ + required this.hosts, + required this.selectHosts, + required this.onEdit, + required this.onLink, + required this.onChecked, + required this.onDelete, + required this.onToggleUse, + required this.onLaunchUrl, + required this.context, + }); + + final List hosts; + final List selectHosts; + final Function(int, HostsModel) onEdit; + final Function(int, HostsModel) onLink; + final Function(int, HostsModel) onChecked; + final Function(List) onDelete; + final Function(Map) onToggleUse; + final Function(String) onLaunchUrl; + final BuildContext context; + + @override + List get rows => hosts.map((host) { + bool isLink = false; + if (host.config.isNotEmpty) { + isLink = + host.config["same"] != null && host.config["contrary"] != null; + } + + return DataGridRow(cells: [ + DataGridCell( + columnName: 'checkbox', + value: Checkbox( + value: selectHosts.contains(host), + onChanged: (bool? newValue) => + onChecked(hosts.indexOf(host), host), + ), + ), + DataGridCell( + columnName: 'host', + value: MyDataGridCell( + value: host.host, + child: GestureDetector( + onTap: () async => await _launchUrl(host.host, context), + child: Container( + alignment: Alignment.centerLeft, + child: Text.rich( + TextSpan( + children: [ + if (isLink) + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + Icons.link, + color: Theme.of(context).colorScheme.primary, + size: 18, + ), + ), + ), + TextSpan( + text: host.host, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + )), + ), + DataGridCell( + columnName: 'use', + value: MyDataGridCell( + value: host.isUse, + child: Switch( + value: host.isUse, + onChanged: (value) { + // Map<旧, 新> + final Map updateUseHosts = { + host: host.withCopy(isUse: value) + }; + void updateHostStates(List hostNames, bool isUse) { + for (var tempHost in hosts + .where((item) => hostNames.contains(item.host))) { + updateUseHosts[tempHost] = + tempHost.withCopy(isUse: isUse); + } + } + + if (host.config["same"] != null) { + updateHostStates( + (host.config["same"] as List).cast(), + value); + } + if (host.config["contrary"] != null) { + updateHostStates( + (host.config["contrary"] as List) + .cast(), + !value); + } + onToggleUse(updateUseHosts); + }, + ), + ), + ), + DataGridCell( + columnName: 'hosts', + value: MyDataGridCell( + value: host.hosts, + child: Container( + alignment: Alignment.centerLeft, + child: Text.rich( + TextSpan( + children: _buildTextSpans(host.hosts, context), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ), + DataGridCell( + columnName: 'description', + value: MyDataGridCell( + value: host.description, + child: Container( + alignment: Alignment.centerLeft, + child: SelectableText(host.description)), + ), + ), + DataGridCell( + columnName: 'actions', + value: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + onPressed: () => onEdit(hosts.indexOf(host), host), + icon: const Icon(Icons.edit), + tooltip: AppLocalizations.of(context)!.edit, + ), + const SizedBox(width: 8), + IconButton( + onPressed: () => onDelete([host]), + icon: const Icon(Icons.delete_outline), + tooltip: AppLocalizations.of(context)!.delete, + ), + const SizedBox(width: 8), + buildMoreButton(context, hosts.indexOf(host), host), + ], + ), + ), + ]); + }).toList(); + + @override + Future performSorting(List rows) async { + // sortedColumns 是父类提供的属性,里面包含排序列及排序方向等信息。 + if (sortedColumns.isNotEmpty) { + final sortColumn = sortedColumns.first; + // SortColumnDetails 通常包含 columnName 和 sortDirection + final String columnName = sortColumn.name; + final bool isAscending = + sortColumn.sortDirection == DataGridSortDirection.ascending; + if (!["checkbox", "actions"].contains(columnName)) { + rows.sort((a, b) { + final valueA = a + .getCells() + .firstWhere((cell) => cell.columnName == columnName) + .value + .toString(); + final valueB = b + .getCells() + .firstWhere((cell) => cell.columnName == columnName) + .value + .toString(); + + return isAscending + ? valueA.compareTo(valueB) + : valueB.compareTo(valueA); + }); + } + } + // notifyListeners(); + } + + Future _launchUrl(String host, BuildContext context) async { + final url = Uri.parse('http://$host'); + if (await canLaunchUrl(url)) { + await launchUrl(url); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.unable_to_open(host))), + ); + } + } + } + + List _buildTextSpans(List hosts, BuildContext context) { + List textSpans = []; + for (int i = 0; i < hosts.length; i++) { + textSpans.add(TextSpan( + text: hosts[i], + recognizer: TapGestureRecognizer() + ..onTap = () async { + await _launchUrl(hosts[i], context); + }, + )); + if (i < hosts.length - 1) { + textSpans.add(TextSpan( + text: ' - ', + style: TextStyle( + color: Theme.of(context).colorScheme.inverseSurface, + fontWeight: FontWeight.w900, + ), + )); + } + } + return textSpans; + } + + Widget buildMoreButton(BuildContext context, int index, HostsModel host) { + return PopupMenuButton( + onSelected: (value) async { + switch (value) { + case 1: + onLink(index, host); + break; + case 2: + testDialog(context, host); + break; + case 3: + copyDialog(context, hosts, index); + break; + } + }, + itemBuilder: (BuildContext context) { + List> list = [ + { + "icon": Icons.link, + "text": AppLocalizations.of(context)!.link, + "value": 1 + }, + { + "icon": Icons.sensors, + "text": AppLocalizations.of(context)!.test, + "value": 2 + }, + { + "icon": Icons.copy, + "text": AppLocalizations.of(context)!.copy, + "value": 3 + }, + ]; + return list + .where((item) => !(item["value"] == 2 && kIsWeb)) + .map((item) { + return PopupMenuItem( + value: int.parse(item["value"].toString()), + child: Row( + children: [ + Icon(item["icon"]! as IconData), + const SizedBox(width: 8), + Text(item["text"]!.toString()), + ], + ), + ); + }).toList(); + }, + ); + } + + @override + DataGridRowAdapter? buildRow(DataGridRow row) { + return DataGridRowAdapter( + cells: + row.getCells().map((dataCell) => dataCell.value as Widget).toList(), + ); + } +} + +class MyDataGridCell extends StatelessWidget { + final Widget child; + final dynamic value; + + const MyDataGridCell({super.key, required this.child, required this.value}); + + @override + Widget build(BuildContext context) { + return child; + } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return value.toString(); + } +} diff --git a/lib/home/view/host_text.dart b/lib/home/view/host_text.dart new file mode 100644 index 0000000..a442d75 --- /dev/null +++ b/lib/home/view/host_text.dart @@ -0,0 +1,229 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_keyboard_visibility_temp_fork/flutter_keyboard_visibility_temp_fork.dart'; +import 'package:hosts/home/cubit/host_cubit.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/widget/host_text_editing_controller.dart'; +import 'package:hosts/widget/row_line_widget.dart'; + +/// 主机文本编辑组件 +/// +/// 提供主机文件的文本编辑功能,包括: +/// - 行号显示 +/// - 快捷键操作 +/// - 文本内容同步 +class HostText extends StatefulWidget { + /// 构造函数 + const HostText({super.key}); + + @override + State createState() => _HostTextState(); +} + +/// HostText组件的状态类 +/// +/// 管理文本编辑器的状态和交互逻辑,包括: +/// - 文本控制器初始化 +/// - 快捷键处理 +/// - 滚动同步 +class _HostTextState extends State { + HostTextEditingController textEditingController = HostTextEditingController(); + final FocusNode _focusNode = FocusNode(); + bool isControl = false; + final ScrollController _scrollController = ScrollController(); + final ScrollController _textScrollController = ScrollController(); + final GlobalKey _textFieldContainerKey = GlobalKey(); + bool _isScrollingSynchronized = false; + + @override + void initState() { + final hostCubit = context.read(); + textEditingController + ..text = hostCubit.state.data.fileContent + ..addListener(() { + hostCubit.updateFileContent(textEditingController.text); + }); + + // Synchronize scroll controllers + _textScrollController.addListener(() { + if (!_isScrollingSynchronized && _scrollController.hasClients) { + _isScrollingSynchronized = true; + _scrollController.jumpTo(_textScrollController.offset); + _isScrollingSynchronized = false; + } + }); + + _scrollController.addListener(() { + if (!_isScrollingSynchronized && _textScrollController.hasClients) { + _isScrollingSynchronized = true; + _textScrollController.jumpTo(_scrollController.offset); + _isScrollingSynchronized = false; + } + }); + + super.initState(); + } + + @override + void dispose() { + textEditingController.dispose(); + _scrollController.dispose(); + _textScrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return KeyboardVisibilityBuilder( + builder: (context, isKeyboardVisible) { + return BlocBuilder( + builder: (BuildContext context, state) { + if (state is HostUndo || state is HostInitial || state is HostHistory) { + // 销毁旧的控制器 + textEditingController.dispose(); + + // 创建新的控制器 + textEditingController = HostTextEditingController(); + textEditingController.text = state.data.fileContent; + + // 重新添加监听器 + final hostCubit = context.read(); + textEditingController.addListener(() { + hostCubit.updateFileContent(textEditingController.text); + }); + + _scrollController.jumpTo(0); + } + final hostCubit = context.read(); + return Column( + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric( + vertical: Platform.isIOS || Platform.isAndroid ? 4 : 0, + ), + child: RowLineWidget( + textEditingController: textEditingController, + context: context, + textFieldContainerKey: _textFieldContainerKey, + scrollController: _scrollController, + ), + ), + Expanded( + key: _textFieldContainerKey, + child: GestureDetector( + onTap: () { + _focusNode.requestFocus(); + }, + child: KeyboardListener( + focusNode: _focusNode, + onKeyEvent: (event) { + List logicalKeys = []; + if (Platform.isMacOS) { + logicalKeys = [ + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight + ]; + } else { + logicalKeys = [ + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight + ]; + } + if (logicalKeys.contains(event.logicalKey)) { + if (isControl) { + isControl = false; + } else { + isControl = true; + } + } + if (event.logicalKey == LogicalKeyboardKey.slash && + isControl && + event is KeyDownEvent) { + textEditingController.updateUseStatus( + textEditingController.selection); + } + + if (event.logicalKey == LogicalKeyboardKey.keyS && + isControl && + event is KeyDownEvent && + !state.data.isSave) { + hostCubit.onTextSave( + context, textEditingController.text); + } + }, + child: LayoutBuilder( + builder: (context, constraints) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.maxWidth, + minHeight: constraints.maxHeight, + ), + child: IntrinsicWidth( + child: Padding( + padding: EdgeInsets.only( + top: (Platform.isIOS || Platform.isAndroid) && + isKeyboardVisible && !state.data.isSave ? 16 : 0, + ), + child: TextField( + controller: textEditingController, + scrollController: _textScrollController, + maxLines: null, + expands: true, + scrollPadding: EdgeInsets.zero, + scrollPhysics: + const ClampingScrollPhysics(), + decoration: InputDecoration( + border: InputBorder.none, + hintText: + AppLocalizations.of(context)! + .create_host_template), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + children: [ + Text( + "${AppLocalizations.of(context)!.current_line}${textEditingController.countNewlines(textEditingController.text.substring(0, textEditingController.selection.start > 0 ? textEditingController.selection.start : 0)) + 1}", + ), + const SizedBox( + width: 8, + ), + Text( + "${AppLocalizations.of(context)!.total_lines}${textEditingController.countNewlines(textEditingController.text) + 1}"), + ], + ), + ) + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/home/view/host_view.dart b/lib/home/view/host_view.dart new file mode 100644 index 0000000..af0e08d --- /dev/null +++ b/lib/home/view/host_view.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hosts/enums.dart'; +import 'package:hosts/home/cubit/home_cubit.dart'; +import 'package:hosts/home/cubit/host_cubit.dart'; +import 'package:hosts/home/view/host_list.dart'; +import 'package:hosts/home/view/host_table.dart'; +import 'package:hosts/home/view/host_text.dart'; +import 'package:hosts/widget/error/error_empty.dart'; + +/// 主机视图组件 +/// +/// 根据编辑模式和屏幕尺寸显示不同的主机视图: +/// - 文本编辑模式显示HostText +/// - 表格模式显示HostTable +/// - 列表模式显示HostList +class HostView extends StatelessWidget { + /// 主页状态数据 + final HomeStateData data; + + /// 构造函数 + /// + /// [data]: 主页状态数据 + /// [key]: 组件key + const HostView(this.data, {super.key}); + + /// 构建组件布局 + /// + /// [context]: 构建上下文 + /// 返回: 根据编辑模式和屏幕尺寸返回对应的视图组件 + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, state) { + if (data.editMode == EditMode.Text) { + return Expanded( + child: HostText(), + ); + } + + if (state.data.filterHosts.isEmpty) { + return Expanded( + child: Container( + alignment: Alignment.center, + width: double.maxFinite, + height: double.maxFinite, + child: const ErrorEmpty(), + ), + ); + } + + if (MediaQuery.of(context).size.width >= 1000) { + return Expanded(child: HostTable()); + } + + return Expanded(child: HostList()); + }); + } +} diff --git a/lib/home/view/hosts_diff_page.dart b/lib/home/view/hosts_diff_page.dart new file mode 100644 index 0000000..f31049f --- /dev/null +++ b/lib/home/view/hosts_diff_page.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:hosts/widget/hosts_diff_viewer.dart'; +import 'package:hosts/l10n/app_localizations.dart'; + +class HostsDiffPage extends StatelessWidget { + final String historyContent; + final String currentContent; + final String historyLabel; + final String currentLabel; + + const HostsDiffPage({ + Key? key, + required this.historyContent, + required this.currentContent, + required this.historyLabel, + required this.currentLabel, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.hosts_diff_title), + actions: [ + IconButton( + icon: Icon(Icons.info_outline), + onPressed: () => _showDiffLegend(context), + ), + ], + ), + body: Padding( + padding: EdgeInsets.all(16), + child: Column( + children: [ + // 统计信息 + _buildDiffStats(historyContent, currentContent, context), + SizedBox(height: 16), + + // 差异显示 + Expanded( + child: HostsDiffViewer( + oldContent: historyContent, + newContent: currentContent, + oldLabel: historyLabel, + newLabel: currentLabel, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDiffStats(String oldContent, String newContent, BuildContext context) { + final oldLines = oldContent.split('\n').length; + final newLines = newContent.split('\n').length; + final diff = newLines - oldLines; + + return Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem( + context, + AppLocalizations.of(context)!.diff_stats_history, + '$oldLines ${AppLocalizations.of(context)!.history_count}', + Icons.history, + ), + _buildStatItem( + context, + AppLocalizations.of(context)!.diff_stats_current, + '$newLines ${AppLocalizations.of(context)!.history_count}', + Icons.description, + ), + _buildStatItem( + context, + AppLocalizations.of(context)!.diff_stats_difference, + '${diff > 0 ? '+' : ''}$diff ${AppLocalizations.of(context)!.history_count}', + diff > 0 ? Icons.add : (diff < 0 ? Icons.remove : Icons.check), + color: diff > 0 + ? Theme.of(context).colorScheme.primary + : (diff < 0 ? Theme.of(context).colorScheme.error : null), + ), + ], + ), + ); + } + + Widget _buildStatItem( + BuildContext context, + String label, + String value, + IconData icon, { + Color? color, + }) { + return Column( + children: [ + Icon(icon, size: 20, color: color), + SizedBox(height: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall, + ), + Text( + value, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ); + } + + void _showDiffLegend(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.diff_legend_description), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildLegendItem( + context, + AppLocalizations.of(context)!.diff_legend_added, + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3), + ), + SizedBox(height: 8), + _buildLegendItem( + context, + AppLocalizations.of(context)!.diff_legend_deleted, + Theme.of(context).colorScheme.error, + Theme.of(context).colorScheme.errorContainer.withOpacity(0.3), + hasStrikethrough: true, + ), + SizedBox(height: 8), + _buildLegendItem( + context, + AppLocalizations.of(context)!.diff_legend_unchanged, + Theme.of(context).colorScheme.onSurface, + null, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.diff_legend_ok), + ), + ], + ), + ); + } + + Widget _buildLegendItem( + BuildContext context, + String text, + Color textColor, + Color? backgroundColor, { + bool hasStrikethrough = false, + }) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + text, + style: TextStyle( + color: textColor, + decoration: hasStrikethrough ? TextDecoration.lineThrough : null, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/home/view/simple_home_view.dart b/lib/home/view/simple_home_view.dart new file mode 100644 index 0000000..9d24338 --- /dev/null +++ b/lib/home/view/simple_home_view.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hosts/enums.dart'; +import 'package:hosts/home/cubit/home_cubit.dart'; +import 'package:hosts/home/cubit/host_cubit.dart'; +import 'package:hosts/home/view/home_app_bar.dart'; +import 'package:hosts/home/view/host_page.dart'; +import 'package:hosts/home/view/host_view.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/global_settings.dart'; +import 'package:hosts/model/host_file.dart'; + +class SimpleHomeView extends StatelessWidget { + const SimpleHomeView({super.key}); + + @override + Widget build(BuildContext context) { + if (!kIsWeb && + GlobalSettings().filePath != null && + File(GlobalSettings().filePath!).existsSync()) { + context + .read() + .fromText(File(GlobalSettings().filePath!).readAsStringSync()); + } + + return Scaffold( + floatingActionButton: BlocBuilder( + builder: (context, state) { + if (state.data.editMode == EditMode.Table) { + return FloatingActionButton( + onPressed: () async { + List? hostsModels = await Navigator.of(context) + .push(MaterialPageRoute( + builder: (context) => const HostPage())); + if (hostsModels == null) return; + context.read().addHosts(hostsModels); + }, + child: const Icon(Icons.add), + ); + } + return const SizedBox(); + }, + ), + body: SafeArea( + child: BlocBuilder( + builder: (context, state) { + if (state is HomeEditMode) { + context.read().updateEditMode(state.data.editMode); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const HomeAppBar(), + saveTipMessage(state.data), + HostView(state.data) + ], + ); + }, + ), + ), + ); + } + + Widget saveTipMessage(HomeStateData data) { + return BlocBuilder(builder: (context, state) { + final hostCubit = context.read(); + if (state.data.isSave) { + return const SizedBox(); + } + + return MaterialBanner( + content: Text(AppLocalizations.of(context)!.error_not_update_save_tip), + leading: const Icon(Icons.error_outline), + actions: [ + TextButton( + onPressed: () { + saveHost(context, hostCubit.state.data.fileContent); + }, + child: Text(AppLocalizations.of(context)!.save), + ), + ], + ); + }); + } + + Future saveHost(BuildContext context, String hostContent) async { + final hostCubit = context.read(); + final result = await hostCubit.saveHost(context, GlobalSettings().filePath!, hostContent); + if (result) { + hostCubit.fromText(hostContent); + } + return result; + } + + void writeClipboard(String hostContent, String defaultContent, + BuildContext context, HostCubit hostCubit) { + Clipboard.setData(ClipboardData(text: hostContent)).then((_) { + hostCubit.fromText(defaultContent); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.copy_to_tip), + ), + ); + }); + } +} diff --git a/lib/host_observer.dart b/lib/host_observer.dart new file mode 100644 index 0000000..8d58279 --- /dev/null +++ b/lib/host_observer.dart @@ -0,0 +1,16 @@ +import 'package:bloc/bloc.dart'; + +/// 主机观察者类 +/// 用于观察应用中所有BLoC状态变化的观察者 +class HostObserver extends BlocObserver { + /// 构造函数 + const HostObserver(); + + @override + void onChange(BlocBase bloc, Change change) { + super.onChange(bloc, change); + // 打印状态变化日志(忽略避免打印的警告) + // ignore: avoid_print + print('${bloc.runtimeType} $change'); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb old mode 100644 new mode 100755 index 4a6a83c..ad7a17a --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -56,6 +56,9 @@ "link": "Link", "delete": "Delete", "open_file": "Open file", + "export": "Export", + "export_data": "Export hosts data", + "export_success": "File exported successfully", "error_open_file": "Failed to read the file", "error_open_file_size": "The file size cannot exceed 10MB", "about": "About", @@ -65,5 +68,117 @@ "link_and_description": "When ", "link_status_update_description": " the status changes, the following data switches to", "link_status_description": "Status:", - "form": "Form" + "form": "Form", + "import_data": "Import Hosts Data", + "import_success": "Import successful", + "loading": "Loading", + "file_processing": "Processing file", + "import_file": "Import file", + "will_overwrite": "Will overwrite existing file", + "remote_sync": "Remote Sync", + "import": "Import", + "server_settings": "Server Settings", + "server_status": "Server Status", + "server_config": "Server Configuration", + "server_running": "Running", + "server_stopped": "Stopped", + "server_start": "Start", + "server_stop": "Stop", + "server_restart": "Restart", + "server_host": "Host Address", + "server_port": "Port", + "server_auto_start": "Auto Start", + "server_auto_start_desc": "Automatically start HTTP server when app launches", + "server_save_config": "Save Configuration", + "server_copy_url": "Copy URL", + "server_url_copied": "URL copied to clipboard", + "server_started": "Server started", + "server_stopped_msg": "Server stopped", + "server_config_saved": "Configuration saved successfully", + "server_operation_failed": "Operation failed", + "server_invalid_port": "Port must be between 1-65535", + "server_invalid_host": "Host address cannot be empty", + "api_docs": "API Documentation", + "api_endpoints": "Available API endpoints", + "refresh_status": "Refresh status", + "server_address": "Server address", + "copy_url": "Copy URL", + "operation_failed": "Operation failed", + "load_server_settings_failed": "Failed to load server settings", + "get_all_hosts_files": "Get all hosts files", + "get_specific_hosts_file": "Get specific hosts file content (plain text)", + "get_hosts_file_history": "Get hosts file history", + "get_specific_history_content": "Get specific history content (plain text)", + "server_already_running": "Server is already running", + "http_server_start_success": "HTTP server started successfully", + "http_server_start_failed": "Failed to start HTTP server", + "http_server_stopped": "HTTP server stopped", + "missing_file_id": "Missing file ID", + "read_file_failed": "Failed to read file", + "missing_file_id_or_history_id": "Missing file ID or history ID", + "history_not_found": "History not found", + "read_history_failed": "Failed to read history", + "select_hosts_to_export": "Please select hosts files to export", + "select_all": "Select All", + "selected_count": "Selected", + "export_failed": "Export failed", + "nearby_devices": "Nearby Devices", + "scan_nearby_devices": "Scan nearby devices", + "no_nearby_devices": "No devices with sharing enabled found\nClick refresh button to scan nearby devices", + "scanning_devices": "Scanning nearby devices...", + "sharing_enabled": "Sharing service enabled", + "device_reachable": "Device reachable", + "visit_device": "Visit device", + "access_denied_file_not_allowed": "Access denied: File not allowed", + "select_hosts_to_share": "Please select hosts files to share", + "offline": "Offline", + "scan_nearby_devices_failed": "Failed to scan nearby devices", + "import_remote_hosts": "Import remote hosts files", + "refresh": "Refresh", + "getting_remote_hosts": "Getting remote hosts files...", + "connection_failed": "Failed to connect to device", + "retry": "Retry", + "select_all": "Select All", + "no_hosts_files_found": "No available hosts files found", + "device_no_shared_files": "This device may not be sharing any hosts files", + "no_importable_content": "No importable file content found", + "remote_files": " remote files", + "show_qr_code": "Show QR Code", + "port": "Port", + "hosts_diff_title": "Hosts Diff Comparison", + "diff_legend_added": "Added Content", + "diff_legend_deleted": "Deleted Content", + "diff_legend_unchanged": "Unchanged Content", + "diff_stats_history": "Historical Version", + "diff_stats_current": "Current Version", + "diff_stats_difference": "Difference", + "history_count": "History records", + "getting_device_info": "Getting device information...", + "get_device_info_failed": "Failed to get device information", + "basic_api": "Basic API", + "available_hosts_files_api": "Available Hosts files API:", + "device_no_hosts_files": "This device has no available hosts files", + "history_content": "History content", + "history_count_suffix": " history records", + "get_content_prefix": "Get", + "get_content_suffix": "content", + "scan_qr_code_to_access": "Scan QR code to access", + "open_in_browser": "Open in browser", + "show_qr_code_tooltip": "Show QR code", + "copy_url_tooltip": "Copy URL", + "get_history_content_prefix": "Get history record", + "get_history_content_suffix": "content", + "more_history_records": "... ", + "more_history_records_suffix": " more history records", + "view_diff": "View diff", + "history_version": "History version", + "current_version": "Current version", + "unable_to_read_history_file": "Unable to read history file", + "diff_legend_description": "Difference Legend", + "diff_legend_ok": "Got it", + "current_line": "Current line:", + "total_lines": "Total lines:", + "unable_to_open": "Unable to open {host}", + "check_for_updates": "Check for Updates", + "report_issue": "Report Issue" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100755 index 0000000..6444005 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,1219 @@ +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 @app_name. + /// + /// In zh, this message translates to: + /// **'Hosts 编辑器'** + String get app_name; + + /// No description provided for @ok. + /// + /// In zh, this message translates to: + /// **'确认'** + String get ok; + + /// No description provided for @cancel. + /// + /// In zh, this message translates to: + /// **'取消'** + String get cancel; + + /// No description provided for @add. + /// + /// In zh, this message translates to: + /// **'新增'** + String get add; + + /// No description provided for @create. + /// + /// In zh, this message translates to: + /// **'创建'** + String get create; + + /// No description provided for @edit. + /// + /// In zh, this message translates to: + /// **'编辑'** + String get edit; + + /// No description provided for @remove. + /// + /// In zh, this message translates to: + /// **'删除'** + String get remove; + + /// No description provided for @abort. + /// + /// In zh, this message translates to: + /// **'舍弃'** + String get abort; + + /// No description provided for @remark. + /// + /// In zh, this message translates to: + /// **'备注'** + String get remark; + + /// No description provided for @info. + /// + /// In zh, this message translates to: + /// **'信息'** + String get info; + + /// No description provided for @input_remark. + /// + /// In zh, this message translates to: + /// **'请输入备注'** + String get input_remark; + + /// No description provided for @remove_single_tip. + /// + /// In zh, this message translates to: + /// **'您确认需要删除《{name}》吗?'** + String remove_single_tip(Object name); + + /// No description provided for @remove_multiple_tip. + /// + /// In zh, this message translates to: + /// **'确认删除选中的{count}条记录吗?'** + String remove_multiple_tip(Object count); + + /// No description provided for @save. + /// + /// In zh, this message translates to: + /// **'保存'** + String get save; + + /// No description provided for @save_create_history. + /// + /// In zh, this message translates to: + /// **'保存并生成历史'** + String get save_create_history; + + /// No description provided for @default_hosts_text. + /// + /// In zh, this message translates to: + /// **'默认'** + String get default_hosts_text; + + /// No description provided for @input_search. + /// + /// In zh, this message translates to: + /// **'搜索...'** + String get input_search; + + /// No description provided for @use. + /// + /// In zh, this message translates to: + /// **'使用'** + String get use; + + /// No description provided for @prev. + /// + /// In zh, this message translates to: + /// **'上一个'** + String get prev; + + /// No description provided for @next. + /// + /// In zh, this message translates to: + /// **'下一个'** + String get next; + + /// No description provided for @ip_address. + /// + /// In zh, this message translates to: + /// **'IP地址'** + String get ip_address; + + /// No description provided for @input_ip_address. + /// + /// In zh, this message translates to: + /// **'请输入IP地址'** + String get input_ip_address; + + /// No description provided for @input_ip_address_hint. + /// + /// In zh, this message translates to: + /// **'支持IPV4和IPV6'** + String get input_ip_address_hint; + + /// No description provided for @input_ipv4_ipv6. + /// + /// In zh, this message translates to: + /// **'请输入IPV4或IPV6地址'** + String get input_ipv4_ipv6; + + /// No description provided for @create_host_template. + /// + /// In zh, this message translates to: + /// **'模板1 - 未启用:\n# 127.0.0.1 flutter.dev\n\n模板2 - 没备注:\n127.0.0.1 flutter.dev\n\n模板3 - 有备注:\n# Flutter\n127.0.0.1 flutter.dev\n\n...'** + String get create_host_template; + + /// No description provided for @history. + /// + /// In zh, this message translates to: + /// **'历史'** + String get history; + + /// No description provided for @domain. + /// + /// In zh, this message translates to: + /// **'域名'** + String get domain; + + /// No description provided for @input_domain. + /// + /// In zh, this message translates to: + /// **'请输入域名'** + String get input_domain; + + /// No description provided for @error_domain_tip. + /// + /// In zh, this message translates to: + /// **'请不要输入空格(“ ”)和换行(“\n”)。'** + String get error_domain_tip; + + /// No description provided for @error_exist_domain_tip. + /// + /// In zh, this message translates to: + /// **'该域名已存在'** + String get error_exist_domain_tip; + + /// No description provided for @history_remove_tip. + /// + /// In zh, this message translates to: + /// **'历史记录将在5秒后被移除。点击右侧按钮以取消。'** + String get history_remove_tip; + + /// No description provided for @error_null_data. + /// + /// In zh, this message translates to: + /// **'找不到数据'** + String get error_null_data; + + /// No description provided for @error_use_fail. + /// + /// In zh, this message translates to: + /// **'使用失败'** + String get error_use_fail; + + /// No description provided for @error_not_save. + /// + /// In zh, this message translates to: + /// **'当前文件包含未保存的更改'** + String get error_not_save; + + /// No description provided for @error_save_fail. + /// + /// In zh, this message translates to: + /// **'保存失败'** + String get error_save_fail; + + /// No description provided for @table. + /// + /// In zh, this message translates to: + /// **'表格'** + String get table; + + /// No description provided for @text. + /// + /// In zh, this message translates to: + /// **'文本'** + String get text; + + /// No description provided for @copy. + /// + /// In zh, this message translates to: + /// **'复制'** + String get copy; + + /// No description provided for @status. + /// + /// In zh, this message translates to: + /// **'状态'** + String get status; + + /// No description provided for @action. + /// + /// In zh, this message translates to: + /// **'操作'** + String get action; + + /// No description provided for @copy_selected. + /// + /// In zh, this message translates to: + /// **'复制选中'** + String get copy_selected; + + /// No description provided for @delete_selected. + /// + /// In zh, this message translates to: + /// **'删除选中'** + String get delete_selected; + + /// No description provided for @reduction. + /// + /// In zh, this message translates to: + /// **'还原'** + String get reduction; + + /// No description provided for @advanced_settings. + /// + /// In zh, this message translates to: + /// **'高级设置'** + String get advanced_settings; + + /// No description provided for @copy_to_tip. + /// + /// In zh, this message translates to: + /// **'已复制到剪贴板'** + String get copy_to_tip; + + /// No description provided for @warning. + /// + /// In zh, this message translates to: + /// **'警告'** + String get warning; + + /// No description provided for @warning_different. + /// + /// In zh, this message translates to: + /// **'系统 Hosts 文件与当前文件不一致!\n如果您不做覆盖处理,修改后保存当前文件会导致系统文件的数据被覆盖。'** + String get warning_different; + + /// No description provided for @warning_different_covering_system. + /// + /// In zh, this message translates to: + /// **'当前覆盖系统'** + String get warning_different_covering_system; + + /// No description provided for @warning_different_covering_current. + /// + /// In zh, this message translates to: + /// **'系统覆盖当前'** + String get warning_different_covering_current; + + /// No description provided for @error_not_update_save_tip. + /// + /// In zh, this message translates to: + /// **'内容已更新!请确保保存您的更改,以免丢失重要信息。'** + String get error_not_update_save_tip; + + /// No description provided for @error_not_update_save_permission_tip. + /// + /// In zh, this message translates to: + /// **'该文件已被使用保存时需要管理员权限。'** + String get error_not_update_save_permission_tip; + + /// No description provided for @test. + /// + /// In zh, this message translates to: + /// **'测试'** + String get test; + + /// No description provided for @error_test_ip_notfound. + /// + /// In zh, this message translates to: + /// **'未找到 IP 地址'** + String get error_test_ip_notfound; + + /// No description provided for @error_test_ip_different. + /// + /// In zh, this message translates to: + /// **'找到 IP 地址和设置 IP 地址并不一致'** + String get error_test_ip_different; + + /// No description provided for @link. + /// + /// In zh, this message translates to: + /// **'关联'** + String get link; + + /// No description provided for @delete. + /// + /// In zh, this message translates to: + /// **'删除'** + String get delete; + + /// No description provided for @open_file. + /// + /// In zh, this message translates to: + /// **'打开文件'** + String get open_file; + + /// No description provided for @export. + /// + /// In zh, this message translates to: + /// **'导出'** + String get export; + + /// No description provided for @export_data. + /// + /// In zh, this message translates to: + /// **'导出 Hosts 数据'** + String get export_data; + + /// No description provided for @export_success. + /// + /// In zh, this message translates to: + /// **'文件导出成功'** + String get export_success; + + /// No description provided for @error_open_file. + /// + /// In zh, this message translates to: + /// **'文件读取失败'** + String get error_open_file; + + /// No description provided for @error_open_file_size. + /// + /// In zh, this message translates to: + /// **'读取文件不能大于10MB'** + String get error_open_file_size; + + /// No description provided for @about. + /// + /// In zh, this message translates to: + /// **'关于'** + String get about; + + /// No description provided for @about_description. + /// + /// In zh, this message translates to: + /// **'Hosts Editor 是一个使用 Flutter 开发的应用程序,旨在简化 Linux、MacOS、Windows 系统上 hosts 文件的编辑和管理。\n该工具提供了一个用户友好的界面,使用户能够轻松地添加、修改和删除 hosts 文件中的条目。'** + String get about_description; + + /// No description provided for @link_contrary. + /// + /// In zh, this message translates to: + /// **'相反'** + String get link_contrary; + + /// No description provided for @link_same. + /// + /// In zh, this message translates to: + /// **'相同'** + String get link_same; + + /// No description provided for @link_and_description. + /// + /// In zh, this message translates to: + /// **'当 '** + String get link_and_description; + + /// No description provided for @link_status_update_description. + /// + /// In zh, this message translates to: + /// **' 状态变化时,下列数据切换为'** + String get link_status_update_description; + + /// No description provided for @link_status_description. + /// + /// In zh, this message translates to: + /// **'状态:'** + String get link_status_description; + + /// No description provided for @form. + /// + /// In zh, this message translates to: + /// **'表单'** + String get form; + + /// No description provided for @import_data. + /// + /// In zh, this message translates to: + /// **'导入 Hosts 数据'** + String get import_data; + + /// No description provided for @import_success. + /// + /// In zh, this message translates to: + /// **'导入成功'** + String get import_success; + + /// No description provided for @loading. + /// + /// In zh, this message translates to: + /// **'加载中'** + String get loading; + + /// No description provided for @file_processing. + /// + /// In zh, this message translates to: + /// **'文件处理中'** + String get file_processing; + + /// No description provided for @import_file. + /// + /// In zh, this message translates to: + /// **'导入文件'** + String get import_file; + + /// No description provided for @will_overwrite. + /// + /// In zh, this message translates to: + /// **'将覆盖现有文件'** + String get will_overwrite; + + /// No description provided for @remote_sync. + /// + /// In zh, this message translates to: + /// **'远程同步'** + String get remote_sync; + + /// No description provided for @import. + /// + /// In zh, this message translates to: + /// **'导入'** + String get import; + + /// No description provided for @server_settings. + /// + /// In zh, this message translates to: + /// **'服务器设置'** + String get server_settings; + + /// No description provided for @server_status. + /// + /// In zh, this message translates to: + /// **'服务器状态'** + String get server_status; + + /// No description provided for @server_config. + /// + /// In zh, this message translates to: + /// **'服务器配置'** + String get server_config; + + /// No description provided for @server_running. + /// + /// In zh, this message translates to: + /// **'运行中'** + String get server_running; + + /// No description provided for @server_stopped. + /// + /// In zh, this message translates to: + /// **'已停止'** + String get server_stopped; + + /// No description provided for @server_start. + /// + /// In zh, this message translates to: + /// **'启动'** + String get server_start; + + /// No description provided for @server_stop. + /// + /// In zh, this message translates to: + /// **'停止'** + String get server_stop; + + /// No description provided for @server_restart. + /// + /// In zh, this message translates to: + /// **'重启'** + String get server_restart; + + /// No description provided for @server_host. + /// + /// In zh, this message translates to: + /// **'主机地址'** + String get server_host; + + /// No description provided for @server_port. + /// + /// In zh, this message translates to: + /// **'端口'** + String get server_port; + + /// No description provided for @server_auto_start. + /// + /// In zh, this message translates to: + /// **'自动启动'** + String get server_auto_start; + + /// No description provided for @server_auto_start_desc. + /// + /// In zh, this message translates to: + /// **'应用启动时自动启动HTTP服务器'** + String get server_auto_start_desc; + + /// No description provided for @server_save_config. + /// + /// In zh, this message translates to: + /// **'保存配置'** + String get server_save_config; + + /// No description provided for @server_copy_url. + /// + /// In zh, this message translates to: + /// **'复制URL'** + String get server_copy_url; + + /// No description provided for @server_url_copied. + /// + /// In zh, this message translates to: + /// **'URL已复制到剪贴板'** + String get server_url_copied; + + /// No description provided for @server_started. + /// + /// In zh, this message translates to: + /// **'服务器已启动'** + String get server_started; + + /// No description provided for @server_stopped_msg. + /// + /// In zh, this message translates to: + /// **'服务器已停止'** + String get server_stopped_msg; + + /// No description provided for @server_config_saved. + /// + /// In zh, this message translates to: + /// **'配置保存成功'** + String get server_config_saved; + + /// No description provided for @server_operation_failed. + /// + /// In zh, this message translates to: + /// **'操作失败'** + String get server_operation_failed; + + /// No description provided for @server_invalid_port. + /// + /// In zh, this message translates to: + /// **'端口号必须在1-65535之间'** + String get server_invalid_port; + + /// No description provided for @server_invalid_host. + /// + /// In zh, this message translates to: + /// **'主机地址不能为空'** + String get server_invalid_host; + + /// No description provided for @api_docs. + /// + /// In zh, this message translates to: + /// **'API文档'** + String get api_docs; + + /// No description provided for @api_endpoints. + /// + /// In zh, this message translates to: + /// **'可用的API端点'** + String get api_endpoints; + + /// No description provided for @refresh_status. + /// + /// In zh, this message translates to: + /// **'刷新状态'** + String get refresh_status; + + /// No description provided for @server_address. + /// + /// In zh, this message translates to: + /// **'服务器地址'** + String get server_address; + + /// No description provided for @copy_url. + /// + /// In zh, this message translates to: + /// **'复制URL'** + String get copy_url; + + /// No description provided for @operation_failed. + /// + /// In zh, this message translates to: + /// **'操作失败'** + String get operation_failed; + + /// No description provided for @load_server_settings_failed. + /// + /// In zh, this message translates to: + /// **'加载服务器设置失败'** + String get load_server_settings_failed; + + /// No description provided for @get_all_hosts_files. + /// + /// In zh, this message translates to: + /// **'获取所有hosts文件'** + String get get_all_hosts_files; + + /// No description provided for @get_specific_hosts_file. + /// + /// In zh, this message translates to: + /// **'获取特定hosts文件内容(纯文本)'** + String get get_specific_hosts_file; + + /// No description provided for @get_hosts_file_history. + /// + /// In zh, this message translates to: + /// **'获取hosts文件历史记录'** + String get get_hosts_file_history; + + /// No description provided for @get_specific_history_content. + /// + /// In zh, this message translates to: + /// **'获取特定历史记录内容(纯文本)'** + String get get_specific_history_content; + + /// No description provided for @server_already_running. + /// + /// In zh, this message translates to: + /// **'服务器已经在运行中'** + String get server_already_running; + + /// No description provided for @http_server_start_success. + /// + /// In zh, this message translates to: + /// **'HTTP服务器启动成功'** + String get http_server_start_success; + + /// No description provided for @http_server_start_failed. + /// + /// In zh, this message translates to: + /// **'启动HTTP服务器失败'** + String get http_server_start_failed; + + /// No description provided for @http_server_stopped. + /// + /// In zh, this message translates to: + /// **'HTTP服务器已停止'** + String get http_server_stopped; + + /// No description provided for @missing_file_id. + /// + /// In zh, this message translates to: + /// **'缺少文件ID'** + String get missing_file_id; + + /// No description provided for @read_file_failed. + /// + /// In zh, this message translates to: + /// **'读取文件失败'** + String get read_file_failed; + + /// No description provided for @missing_file_id_or_history_id. + /// + /// In zh, this message translates to: + /// **'缺少文件ID或历史记录ID'** + String get missing_file_id_or_history_id; + + /// No description provided for @history_not_found. + /// + /// In zh, this message translates to: + /// **'历史记录不存在'** + String get history_not_found; + + /// No description provided for @read_history_failed. + /// + /// In zh, this message translates to: + /// **'读取历史记录失败'** + String get read_history_failed; + + /// No description provided for @select_hosts_to_export. + /// + /// In zh, this message translates to: + /// **'请选择要导出hosts文件'** + String get select_hosts_to_export; + + /// No description provided for @select_all. + /// + /// In zh, this message translates to: + /// **'全选'** + String get select_all; + + /// No description provided for @selected_count. + /// + /// In zh, this message translates to: + /// **'已选择'** + String get selected_count; + + /// No description provided for @export_failed. + /// + /// In zh, this message translates to: + /// **'导出失败'** + String get export_failed; + + /// No description provided for @nearby_devices. + /// + /// In zh, this message translates to: + /// **'附近设备'** + String get nearby_devices; + + /// No description provided for @scan_nearby_devices. + /// + /// In zh, this message translates to: + /// **'扫描附近设备'** + String get scan_nearby_devices; + + /// No description provided for @no_nearby_devices. + /// + /// In zh, this message translates to: + /// **'没有发现开启共享功能的设备\n点击刷新按钮扫描附近设备'** + String get no_nearby_devices; + + /// No description provided for @scanning_devices. + /// + /// In zh, this message translates to: + /// **'正在扫描附近设备...'** + String get scanning_devices; + + /// No description provided for @sharing_enabled. + /// + /// In zh, this message translates to: + /// **'共享服务已开启'** + String get sharing_enabled; + + /// No description provided for @device_reachable. + /// + /// In zh, this message translates to: + /// **'设备可达'** + String get device_reachable; + + /// No description provided for @visit_device. + /// + /// In zh, this message translates to: + /// **'访问设备'** + String get visit_device; + + /// No description provided for @access_denied_file_not_allowed. + /// + /// In zh, this message translates to: + /// **'访问被拒绝:文件不被允许'** + String get access_denied_file_not_allowed; + + /// No description provided for @select_hosts_to_share. + /// + /// In zh, this message translates to: + /// **'请选择要分享的hosts文件'** + String get select_hosts_to_share; + + /// No description provided for @offline. + /// + /// In zh, this message translates to: + /// **'离线'** + String get offline; + + /// No description provided for @scan_nearby_devices_failed. + /// + /// In zh, this message translates to: + /// **'扫描附近设备失败'** + String get scan_nearby_devices_failed; + + /// No description provided for @import_remote_hosts. + /// + /// In zh, this message translates to: + /// **'导入远程hosts文件'** + String get import_remote_hosts; + + /// No description provided for @refresh. + /// + /// In zh, this message translates to: + /// **'刷新'** + String get refresh; + + /// No description provided for @getting_remote_hosts. + /// + /// In zh, this message translates to: + /// **'正在获取远程hosts文件...'** + String get getting_remote_hosts; + + /// No description provided for @connection_failed. + /// + /// In zh, this message translates to: + /// **'连接设备失败'** + String get connection_failed; + + /// No description provided for @retry. + /// + /// In zh, this message translates to: + /// **'重试'** + String get retry; + + /// No description provided for @no_hosts_files_found. + /// + /// In zh, this message translates to: + /// **'没有找到可用的hosts文件'** + String get no_hosts_files_found; + + /// No description provided for @device_no_shared_files. + /// + /// In zh, this message translates to: + /// **'该设备可能没有共享任何hosts文件'** + String get device_no_shared_files; + + /// No description provided for @no_importable_content. + /// + /// In zh, this message translates to: + /// **'没有找到可导入的文件内容'** + String get no_importable_content; + + /// No description provided for @remote_files. + /// + /// In zh, this message translates to: + /// **'个远程文件'** + String get remote_files; + + /// No description provided for @show_qr_code. + /// + /// In zh, this message translates to: + /// **'显示二维码'** + String get show_qr_code; + + /// No description provided for @port. + /// + /// In zh, this message translates to: + /// **'端口'** + String get port; + + /// No description provided for @hosts_diff_title. + /// + /// In zh, this message translates to: + /// **'Hosts 差异对比'** + String get hosts_diff_title; + + /// No description provided for @diff_legend_added. + /// + /// In zh, this message translates to: + /// **'新增内容'** + String get diff_legend_added; + + /// No description provided for @diff_legend_deleted. + /// + /// In zh, this message translates to: + /// **'删除内容'** + String get diff_legend_deleted; + + /// No description provided for @diff_legend_unchanged. + /// + /// In zh, this message translates to: + /// **'未变更内容'** + String get diff_legend_unchanged; + + /// No description provided for @diff_stats_history. + /// + /// In zh, this message translates to: + /// **'历史版本'** + String get diff_stats_history; + + /// No description provided for @diff_stats_current. + /// + /// In zh, this message translates to: + /// **'当前版本'** + String get diff_stats_current; + + /// No description provided for @diff_stats_difference. + /// + /// In zh, this message translates to: + /// **'差异'** + String get diff_stats_difference; + + /// No description provided for @history_count. + /// + /// In zh, this message translates to: + /// **'历史记录'** + String get history_count; + + /// No description provided for @getting_device_info. + /// + /// In zh, this message translates to: + /// **'正在获取设备信息...'** + String get getting_device_info; + + /// No description provided for @get_device_info_failed. + /// + /// In zh, this message translates to: + /// **'获取设备信息失败'** + String get get_device_info_failed; + + /// No description provided for @basic_api. + /// + /// In zh, this message translates to: + /// **'基础 API'** + String get basic_api; + + /// No description provided for @available_hosts_files_api. + /// + /// In zh, this message translates to: + /// **'可用的Hosts文件 API:'** + String get available_hosts_files_api; + + /// No description provided for @device_no_hosts_files. + /// + /// In zh, this message translates to: + /// **'该设备暂无可用的hosts文件'** + String get device_no_hosts_files; + + /// No description provided for @history_content. + /// + /// In zh, this message translates to: + /// **'历史记录内容'** + String get history_content; + + /// No description provided for @history_count_suffix. + /// + /// In zh, this message translates to: + /// **'条历史'** + String get history_count_suffix; + + /// No description provided for @get_content_prefix. + /// + /// In zh, this message translates to: + /// **'获取'** + String get get_content_prefix; + + /// No description provided for @get_content_suffix. + /// + /// In zh, this message translates to: + /// **'的内容'** + String get get_content_suffix; + + /// No description provided for @scan_qr_code_to_access. + /// + /// In zh, this message translates to: + /// **'扫描二维码访问'** + String get scan_qr_code_to_access; + + /// No description provided for @open_in_browser. + /// + /// In zh, this message translates to: + /// **'在浏览器中打开'** + String get open_in_browser; + + /// No description provided for @show_qr_code_tooltip. + /// + /// In zh, this message translates to: + /// **'显示二维码'** + String get show_qr_code_tooltip; + + /// No description provided for @copy_url_tooltip. + /// + /// In zh, this message translates to: + /// **'复制URL'** + String get copy_url_tooltip; + + /// No description provided for @get_history_content_prefix. + /// + /// In zh, this message translates to: + /// **'获取历史记录'** + String get get_history_content_prefix; + + /// No description provided for @get_history_content_suffix. + /// + /// In zh, this message translates to: + /// **'的内容'** + String get get_history_content_suffix; + + /// No description provided for @more_history_records. + /// + /// In zh, this message translates to: + /// **'... 还有'** + String get more_history_records; + + /// No description provided for @more_history_records_suffix. + /// + /// In zh, this message translates to: + /// **'条历史记录'** + String get more_history_records_suffix; + + /// No description provided for @view_diff. + /// + /// In zh, this message translates to: + /// **'查看差异'** + String get view_diff; + + /// No description provided for @history_version. + /// + /// In zh, this message translates to: + /// **'历史版本'** + String get history_version; + + /// No description provided for @current_version. + /// + /// In zh, this message translates to: + /// **'当前版本'** + String get current_version; + + /// No description provided for @unable_to_read_history_file. + /// + /// In zh, this message translates to: + /// **'无法读取历史文件'** + String get unable_to_read_history_file; + + /// No description provided for @diff_legend_description. + /// + /// In zh, this message translates to: + /// **'差异说明'** + String get diff_legend_description; + + /// No description provided for @diff_legend_ok. + /// + /// In zh, this message translates to: + /// **'知道了'** + String get diff_legend_ok; + + /// No description provided for @current_line. + /// + /// In zh, this message translates to: + /// **'当前行:'** + String get current_line; + + /// No description provided for @total_lines. + /// + /// In zh, this message translates to: + /// **'总行数:'** + String get total_lines; + + /// No description provided for @unable_to_open. + /// + /// In zh, this message translates to: + /// **'无法打开 {host}'** + String unable_to_open(Object host); + + /// No description provided for @check_for_updates. + /// + /// In zh, this message translates to: + /// **'检查更新'** + String get check_for_updates; + + /// No description provided for @report_issue. + /// + /// In zh, this message translates to: + /// **'反馈问题'** + String get report_issue; +} + +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/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100755 index 0000000..e2e8ec7 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,575 @@ +// 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 app_name => 'Hosts Editor'; + + @override + String get ok => 'Confirm'; + + @override + String get cancel => 'Cancel'; + + @override + String get add => 'Add'; + + @override + String get create => 'Create'; + + @override + String get edit => 'Edit'; + + @override + String get remove => 'Delete'; + + @override + String get abort => 'Discard'; + + @override + String get remark => 'Remark'; + + @override + String get info => 'Information'; + + @override + String get input_remark => 'Please enter a remark'; + + @override + String remove_single_tip(Object name) { + return 'Are you sure you want to delete \'$name\'?'; + } + + @override + String remove_multiple_tip(Object count) { + return 'Are you sure you want to delete the selected $count records?'; + } + + @override + String get save => 'Save'; + + @override + String get save_create_history => 'Save and generate history'; + + @override + String get default_hosts_text => 'Default'; + + @override + String get input_search => 'Search...'; + + @override + String get use => 'Use'; + + @override + String get prev => 'Previous'; + + @override + String get next => 'Next'; + + @override + String get ip_address => 'IP Address'; + + @override + String get input_ip_address => 'Please enter IP address'; + + @override + String get input_ip_address_hint => 'Supports IPV4 and IPV6'; + + @override + String get input_ipv4_ipv6 => 'Please enter IPV4 or IPV6 address'; + + @override + String get create_host_template => + 'Template 1 - Not enabled:\n# 127.0.0.1 flutter.dev\n\nTemplate 2 - No remark:\n127.0.0.1 flutter.dev\n\nTemplate 3 - With remark:\n# Flutter\n127.0.0.1 flutter.dev\n\n...'; + + @override + String get history => 'History'; + + @override + String get domain => 'Domain'; + + @override + String get input_domain => 'Please enter domain'; + + @override + String get error_domain_tip => + 'Please do not enter spaces (\' \') or new lines (\'\n\').'; + + @override + String get error_exist_domain_tip => 'This domain already exists'; + + @override + String get history_remove_tip => + 'The history will be removed in 5 seconds. Click the button on the right to cancel.'; + + @override + String get error_null_data => 'No data found'; + + @override + String get error_use_fail => 'Use failed'; + + @override + String get error_not_save => 'The current file contains unsaved changes'; + + @override + String get error_save_fail => 'Save failed'; + + @override + String get table => 'Table'; + + @override + String get text => 'Text'; + + @override + String get copy => 'Copy'; + + @override + String get status => 'Status'; + + @override + String get action => 'Action'; + + @override + String get copy_selected => 'Copy selected'; + + @override + String get delete_selected => 'Delete selected'; + + @override + String get reduction => 'Restore'; + + @override + String get advanced_settings => 'Advanced Settings'; + + @override + String get copy_to_tip => 'Copied to clipboard'; + + @override + String get warning => 'Warning'; + + @override + String get warning_different => + 'The system Hosts file is inconsistent with the current file!\nIf you do not handle the overwrite, saving the current file after modification will cause the system file data to be overwritten.'; + + @override + String get warning_different_covering_system => 'Currently covering system'; + + @override + String get warning_different_covering_current => 'System covering current'; + + @override + String get error_not_update_save_tip => + 'Content has been updated! Please ensure to save your changes to avoid losing important information.'; + + @override + String get error_not_update_save_permission_tip => + 'This file is in use and requires administrator permissions to save.'; + + @override + String get test => 'Test'; + + @override + String get error_test_ip_notfound => 'IP address not found'; + + @override + String get error_test_ip_different => + 'Found IP address does not match the set IP address'; + + @override + String get link => 'Link'; + + @override + String get delete => 'Delete'; + + @override + String get open_file => 'Open file'; + + @override + String get export => 'Export'; + + @override + String get export_data => 'Export hosts data'; + + @override + String get export_success => 'File exported successfully'; + + @override + String get error_open_file => 'Failed to read the file'; + + @override + String get error_open_file_size => 'The file size cannot exceed 10MB'; + + @override + String get about => 'About'; + + @override + String get about_description => + 'Hosts Editor is an application developed using Flutter, designed to simplify the editing and management of the hosts file on Linux, MacOS, and Windows systems.\nThis tool provides a user-friendly interface that allows users to easily add, modify, and delete entries in the hosts file.'; + + @override + String get link_contrary => 'Contrary'; + + @override + String get link_same => 'Same'; + + @override + String get link_and_description => 'When '; + + @override + String get link_status_update_description => + ' the status changes, the following data switches to'; + + @override + String get link_status_description => 'Status:'; + + @override + String get form => 'Form'; + + @override + String get import_data => 'Import Hosts Data'; + + @override + String get import_success => 'Import successful'; + + @override + String get loading => 'Loading'; + + @override + String get file_processing => 'Processing file'; + + @override + String get import_file => 'Import file'; + + @override + String get will_overwrite => 'Will overwrite existing file'; + + @override + String get remote_sync => 'Remote Sync'; + + @override + String get import => 'Import'; + + @override + String get server_settings => 'Server Settings'; + + @override + String get server_status => 'Server Status'; + + @override + String get server_config => 'Server Configuration'; + + @override + String get server_running => 'Running'; + + @override + String get server_stopped => 'Stopped'; + + @override + String get server_start => 'Start'; + + @override + String get server_stop => 'Stop'; + + @override + String get server_restart => 'Restart'; + + @override + String get server_host => 'Host Address'; + + @override + String get server_port => 'Port'; + + @override + String get server_auto_start => 'Auto Start'; + + @override + String get server_auto_start_desc => + 'Automatically start HTTP server when app launches'; + + @override + String get server_save_config => 'Save Configuration'; + + @override + String get server_copy_url => 'Copy URL'; + + @override + String get server_url_copied => 'URL copied to clipboard'; + + @override + String get server_started => 'Server started'; + + @override + String get server_stopped_msg => 'Server stopped'; + + @override + String get server_config_saved => 'Configuration saved successfully'; + + @override + String get server_operation_failed => 'Operation failed'; + + @override + String get server_invalid_port => 'Port must be between 1-65535'; + + @override + String get server_invalid_host => 'Host address cannot be empty'; + + @override + String get api_docs => 'API Documentation'; + + @override + String get api_endpoints => 'Available API endpoints'; + + @override + String get refresh_status => 'Refresh status'; + + @override + String get server_address => 'Server address'; + + @override + String get copy_url => 'Copy URL'; + + @override + String get operation_failed => 'Operation failed'; + + @override + String get load_server_settings_failed => 'Failed to load server settings'; + + @override + String get get_all_hosts_files => 'Get all hosts files'; + + @override + String get get_specific_hosts_file => + 'Get specific hosts file content (plain text)'; + + @override + String get get_hosts_file_history => 'Get hosts file history'; + + @override + String get get_specific_history_content => + 'Get specific history content (plain text)'; + + @override + String get server_already_running => 'Server is already running'; + + @override + String get http_server_start_success => 'HTTP server started successfully'; + + @override + String get http_server_start_failed => 'Failed to start HTTP server'; + + @override + String get http_server_stopped => 'HTTP server stopped'; + + @override + String get missing_file_id => 'Missing file ID'; + + @override + String get read_file_failed => 'Failed to read file'; + + @override + String get missing_file_id_or_history_id => 'Missing file ID or history ID'; + + @override + String get history_not_found => 'History not found'; + + @override + String get read_history_failed => 'Failed to read history'; + + @override + String get select_hosts_to_export => 'Please select hosts files to export'; + + @override + String get select_all => 'Select All'; + + @override + String get selected_count => 'Selected'; + + @override + String get export_failed => 'Export failed'; + + @override + String get nearby_devices => 'Nearby Devices'; + + @override + String get scan_nearby_devices => 'Scan nearby devices'; + + @override + String get no_nearby_devices => + 'No devices with sharing enabled found\nClick refresh button to scan nearby devices'; + + @override + String get scanning_devices => 'Scanning nearby devices...'; + + @override + String get sharing_enabled => 'Sharing service enabled'; + + @override + String get device_reachable => 'Device reachable'; + + @override + String get visit_device => 'Visit device'; + + @override + String get access_denied_file_not_allowed => + 'Access denied: File not allowed'; + + @override + String get select_hosts_to_share => 'Please select hosts files to share'; + + @override + String get offline => 'Offline'; + + @override + String get scan_nearby_devices_failed => 'Failed to scan nearby devices'; + + @override + String get import_remote_hosts => 'Import remote hosts files'; + + @override + String get refresh => 'Refresh'; + + @override + String get getting_remote_hosts => 'Getting remote hosts files...'; + + @override + String get connection_failed => 'Failed to connect to device'; + + @override + String get retry => 'Retry'; + + @override + String get no_hosts_files_found => 'No available hosts files found'; + + @override + String get device_no_shared_files => + 'This device may not be sharing any hosts files'; + + @override + String get no_importable_content => 'No importable file content found'; + + @override + String get remote_files => ' remote files'; + + @override + String get show_qr_code => 'Show QR Code'; + + @override + String get port => 'Port'; + + @override + String get hosts_diff_title => 'Hosts Diff Comparison'; + + @override + String get diff_legend_added => 'Added Content'; + + @override + String get diff_legend_deleted => 'Deleted Content'; + + @override + String get diff_legend_unchanged => 'Unchanged Content'; + + @override + String get diff_stats_history => 'Historical Version'; + + @override + String get diff_stats_current => 'Current Version'; + + @override + String get diff_stats_difference => 'Difference'; + + @override + String get history_count => 'History records'; + + @override + String get getting_device_info => 'Getting device information...'; + + @override + String get get_device_info_failed => 'Failed to get device information'; + + @override + String get basic_api => 'Basic API'; + + @override + String get available_hosts_files_api => 'Available Hosts files API:'; + + @override + String get device_no_hosts_files => + 'This device has no available hosts files'; + + @override + String get history_content => 'History content'; + + @override + String get history_count_suffix => ' history records'; + + @override + String get get_content_prefix => 'Get'; + + @override + String get get_content_suffix => 'content'; + + @override + String get scan_qr_code_to_access => 'Scan QR code to access'; + + @override + String get open_in_browser => 'Open in browser'; + + @override + String get show_qr_code_tooltip => 'Show QR code'; + + @override + String get copy_url_tooltip => 'Copy URL'; + + @override + String get get_history_content_prefix => 'Get history record'; + + @override + String get get_history_content_suffix => 'content'; + + @override + String get more_history_records => '... '; + + @override + String get more_history_records_suffix => ' more history records'; + + @override + String get view_diff => 'View diff'; + + @override + String get history_version => 'History version'; + + @override + String get current_version => 'Current version'; + + @override + String get unable_to_read_history_file => 'Unable to read history file'; + + @override + String get diff_legend_description => 'Difference Legend'; + + @override + String get diff_legend_ok => 'Got it'; + + @override + String get current_line => 'Current line:'; + + @override + String get total_lines => 'Total lines:'; + + @override + String unable_to_open(Object host) { + return 'Unable to open $host'; + } + + @override + String get check_for_updates => 'Check for Updates'; + + @override + String get report_issue => 'Report Issue'; +} diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart new file mode 100755 index 0000000..8ab5c8d --- /dev/null +++ b/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,562 @@ +// 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 app_name => 'Hosts 编辑器'; + + @override + String get ok => '确认'; + + @override + String get cancel => '取消'; + + @override + String get add => '新增'; + + @override + String get create => '创建'; + + @override + String get edit => '编辑'; + + @override + String get remove => '删除'; + + @override + String get abort => '舍弃'; + + @override + String get remark => '备注'; + + @override + String get info => '信息'; + + @override + String get input_remark => '请输入备注'; + + @override + String remove_single_tip(Object name) { + return '您确认需要删除《$name》吗?'; + } + + @override + String remove_multiple_tip(Object count) { + return '确认删除选中的$count条记录吗?'; + } + + @override + String get save => '保存'; + + @override + String get save_create_history => '保存并生成历史'; + + @override + String get default_hosts_text => '默认'; + + @override + String get input_search => '搜索...'; + + @override + String get use => '使用'; + + @override + String get prev => '上一个'; + + @override + String get next => '下一个'; + + @override + String get ip_address => 'IP地址'; + + @override + String get input_ip_address => '请输入IP地址'; + + @override + String get input_ip_address_hint => '支持IPV4和IPV6'; + + @override + String get input_ipv4_ipv6 => '请输入IPV4或IPV6地址'; + + @override + String get create_host_template => + '模板1 - 未启用:\n# 127.0.0.1 flutter.dev\n\n模板2 - 没备注:\n127.0.0.1 flutter.dev\n\n模板3 - 有备注:\n# Flutter\n127.0.0.1 flutter.dev\n\n...'; + + @override + String get history => '历史'; + + @override + String get domain => '域名'; + + @override + String get input_domain => '请输入域名'; + + @override + String get error_domain_tip => '请不要输入空格(“ ”)和换行(“\n”)。'; + + @override + String get error_exist_domain_tip => '该域名已存在'; + + @override + String get history_remove_tip => '历史记录将在5秒后被移除。点击右侧按钮以取消。'; + + @override + String get error_null_data => '找不到数据'; + + @override + String get error_use_fail => '使用失败'; + + @override + String get error_not_save => '当前文件包含未保存的更改'; + + @override + String get error_save_fail => '保存失败'; + + @override + String get table => '表格'; + + @override + String get text => '文本'; + + @override + String get copy => '复制'; + + @override + String get status => '状态'; + + @override + String get action => '操作'; + + @override + String get copy_selected => '复制选中'; + + @override + String get delete_selected => '删除选中'; + + @override + String get reduction => '还原'; + + @override + String get advanced_settings => '高级设置'; + + @override + String get copy_to_tip => '已复制到剪贴板'; + + @override + String get warning => '警告'; + + @override + String get warning_different => + '系统 Hosts 文件与当前文件不一致!\n如果您不做覆盖处理,修改后保存当前文件会导致系统文件的数据被覆盖。'; + + @override + String get warning_different_covering_system => '当前覆盖系统'; + + @override + String get warning_different_covering_current => '系统覆盖当前'; + + @override + String get error_not_update_save_tip => '内容已更新!请确保保存您的更改,以免丢失重要信息。'; + + @override + String get error_not_update_save_permission_tip => '该文件已被使用保存时需要管理员权限。'; + + @override + String get test => '测试'; + + @override + String get error_test_ip_notfound => '未找到 IP 地址'; + + @override + String get error_test_ip_different => '找到 IP 地址和设置 IP 地址并不一致'; + + @override + String get link => '关联'; + + @override + String get delete => '删除'; + + @override + String get open_file => '打开文件'; + + @override + String get export => '导出'; + + @override + String get export_data => '导出 Hosts 数据'; + + @override + String get export_success => '文件导出成功'; + + @override + String get error_open_file => '文件读取失败'; + + @override + String get error_open_file_size => '读取文件不能大于10MB'; + + @override + String get about => '关于'; + + @override + String get about_description => + 'Hosts Editor 是一个使用 Flutter 开发的应用程序,旨在简化 Linux、MacOS、Windows 系统上 hosts 文件的编辑和管理。\n该工具提供了一个用户友好的界面,使用户能够轻松地添加、修改和删除 hosts 文件中的条目。'; + + @override + String get link_contrary => '相反'; + + @override + String get link_same => '相同'; + + @override + String get link_and_description => '当 '; + + @override + String get link_status_update_description => ' 状态变化时,下列数据切换为'; + + @override + String get link_status_description => '状态:'; + + @override + String get form => '表单'; + + @override + String get import_data => '导入 Hosts 数据'; + + @override + String get import_success => '导入成功'; + + @override + String get loading => '加载中'; + + @override + String get file_processing => '文件处理中'; + + @override + String get import_file => '导入文件'; + + @override + String get will_overwrite => '将覆盖现有文件'; + + @override + String get remote_sync => '远程同步'; + + @override + String get import => '导入'; + + @override + String get server_settings => '服务器设置'; + + @override + String get server_status => '服务器状态'; + + @override + String get server_config => '服务器配置'; + + @override + String get server_running => '运行中'; + + @override + String get server_stopped => '已停止'; + + @override + String get server_start => '启动'; + + @override + String get server_stop => '停止'; + + @override + String get server_restart => '重启'; + + @override + String get server_host => '主机地址'; + + @override + String get server_port => '端口'; + + @override + String get server_auto_start => '自动启动'; + + @override + String get server_auto_start_desc => '应用启动时自动启动HTTP服务器'; + + @override + String get server_save_config => '保存配置'; + + @override + String get server_copy_url => '复制URL'; + + @override + String get server_url_copied => 'URL已复制到剪贴板'; + + @override + String get server_started => '服务器已启动'; + + @override + String get server_stopped_msg => '服务器已停止'; + + @override + String get server_config_saved => '配置保存成功'; + + @override + String get server_operation_failed => '操作失败'; + + @override + String get server_invalid_port => '端口号必须在1-65535之间'; + + @override + String get server_invalid_host => '主机地址不能为空'; + + @override + String get api_docs => 'API文档'; + + @override + String get api_endpoints => '可用的API端点'; + + @override + String get refresh_status => '刷新状态'; + + @override + String get server_address => '服务器地址'; + + @override + String get copy_url => '复制URL'; + + @override + String get operation_failed => '操作失败'; + + @override + String get load_server_settings_failed => '加载服务器设置失败'; + + @override + String get get_all_hosts_files => '获取所有hosts文件'; + + @override + String get get_specific_hosts_file => '获取特定hosts文件内容(纯文本)'; + + @override + String get get_hosts_file_history => '获取hosts文件历史记录'; + + @override + String get get_specific_history_content => '获取特定历史记录内容(纯文本)'; + + @override + String get server_already_running => '服务器已经在运行中'; + + @override + String get http_server_start_success => 'HTTP服务器启动成功'; + + @override + String get http_server_start_failed => '启动HTTP服务器失败'; + + @override + String get http_server_stopped => 'HTTP服务器已停止'; + + @override + String get missing_file_id => '缺少文件ID'; + + @override + String get read_file_failed => '读取文件失败'; + + @override + String get missing_file_id_or_history_id => '缺少文件ID或历史记录ID'; + + @override + String get history_not_found => '历史记录不存在'; + + @override + String get read_history_failed => '读取历史记录失败'; + + @override + String get select_hosts_to_export => '请选择要导出hosts文件'; + + @override + String get select_all => '全选'; + + @override + String get selected_count => '已选择'; + + @override + String get export_failed => '导出失败'; + + @override + String get nearby_devices => '附近设备'; + + @override + String get scan_nearby_devices => '扫描附近设备'; + + @override + String get no_nearby_devices => '没有发现开启共享功能的设备\n点击刷新按钮扫描附近设备'; + + @override + String get scanning_devices => '正在扫描附近设备...'; + + @override + String get sharing_enabled => '共享服务已开启'; + + @override + String get device_reachable => '设备可达'; + + @override + String get visit_device => '访问设备'; + + @override + String get access_denied_file_not_allowed => '访问被拒绝:文件不被允许'; + + @override + String get select_hosts_to_share => '请选择要分享的hosts文件'; + + @override + String get offline => '离线'; + + @override + String get scan_nearby_devices_failed => '扫描附近设备失败'; + + @override + String get import_remote_hosts => '导入远程hosts文件'; + + @override + String get refresh => '刷新'; + + @override + String get getting_remote_hosts => '正在获取远程hosts文件...'; + + @override + String get connection_failed => '连接设备失败'; + + @override + String get retry => '重试'; + + @override + String get no_hosts_files_found => '没有找到可用的hosts文件'; + + @override + String get device_no_shared_files => '该设备可能没有共享任何hosts文件'; + + @override + String get no_importable_content => '没有找到可导入的文件内容'; + + @override + String get remote_files => '个远程文件'; + + @override + String get show_qr_code => '显示二维码'; + + @override + String get port => '端口'; + + @override + String get hosts_diff_title => 'Hosts 差异对比'; + + @override + String get diff_legend_added => '新增内容'; + + @override + String get diff_legend_deleted => '删除内容'; + + @override + String get diff_legend_unchanged => '未变更内容'; + + @override + String get diff_stats_history => '历史版本'; + + @override + String get diff_stats_current => '当前版本'; + + @override + String get diff_stats_difference => '差异'; + + @override + String get history_count => '历史记录'; + + @override + String get getting_device_info => '正在获取设备信息...'; + + @override + String get get_device_info_failed => '获取设备信息失败'; + + @override + String get basic_api => '基础 API'; + + @override + String get available_hosts_files_api => '可用的Hosts文件 API:'; + + @override + String get device_no_hosts_files => '该设备暂无可用的hosts文件'; + + @override + String get history_content => '历史记录内容'; + + @override + String get history_count_suffix => '条历史'; + + @override + String get get_content_prefix => '获取'; + + @override + String get get_content_suffix => '的内容'; + + @override + String get scan_qr_code_to_access => '扫描二维码访问'; + + @override + String get open_in_browser => '在浏览器中打开'; + + @override + String get show_qr_code_tooltip => '显示二维码'; + + @override + String get copy_url_tooltip => '复制URL'; + + @override + String get get_history_content_prefix => '获取历史记录'; + + @override + String get get_history_content_suffix => '的内容'; + + @override + String get more_history_records => '... 还有'; + + @override + String get more_history_records_suffix => '条历史记录'; + + @override + String get view_diff => '查看差异'; + + @override + String get history_version => '历史版本'; + + @override + String get current_version => '当前版本'; + + @override + String get unable_to_read_history_file => '无法读取历史文件'; + + @override + String get diff_legend_description => '差异说明'; + + @override + String get diff_legend_ok => '知道了'; + + @override + String get current_line => '当前行:'; + + @override + String get total_lines => '总行数:'; + + @override + String unable_to_open(Object host) { + return '无法打开 $host'; + } + + @override + String get check_for_updates => '检查更新'; + + @override + String get report_issue => '反馈问题'; +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb old mode 100644 new mode 100755 index 9b9867d..fa06ed5 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -56,6 +56,9 @@ "link": "关联", "delete": "删除", "open_file": "打开文件", + "export": "导出", + "export_data": "导出 Hosts 数据", + "export_success": "文件导出成功", "error_open_file": "文件读取失败", "error_open_file_size": "读取文件不能大于10MB", "about": "关于", @@ -65,5 +68,117 @@ "link_and_description": "当 ", "link_status_update_description": " 状态变化时,下列数据切换为", "link_status_description": "状态:", - "form": "表单" + "form": "表单", + "import_data": "导入 Hosts 数据", + "import_success": "导入成功", + "loading": "加载中", + "file_processing": "文件处理中", + "import_file": "导入文件", + "will_overwrite": "将覆盖现有文件", + "remote_sync": "远程同步", + "import": "导入", + "server_settings": "服务器设置", + "server_status": "服务器状态", + "server_config": "服务器配置", + "server_running": "运行中", + "server_stopped": "已停止", + "server_start": "启动", + "server_stop": "停止", + "server_restart": "重启", + "server_host": "主机地址", + "server_port": "端口", + "server_auto_start": "自动启动", + "server_auto_start_desc": "应用启动时自动启动HTTP服务器", + "server_save_config": "保存配置", + "server_copy_url": "复制URL", + "server_url_copied": "URL已复制到剪贴板", + "server_started": "服务器已启动", + "server_stopped_msg": "服务器已停止", + "server_config_saved": "配置保存成功", + "server_operation_failed": "操作失败", + "server_invalid_port": "端口号必须在1-65535之间", + "server_invalid_host": "主机地址不能为空", + "api_docs": "API文档", + "api_endpoints": "可用的API端点", + "refresh_status": "刷新状态", + "server_address": "服务器地址", + "copy_url": "复制URL", + "operation_failed": "操作失败", + "load_server_settings_failed": "加载服务器设置失败", + "get_all_hosts_files": "获取所有hosts文件", + "get_specific_hosts_file": "获取特定hosts文件内容(纯文本)", + "get_hosts_file_history": "获取hosts文件历史记录", + "get_specific_history_content": "获取特定历史记录内容(纯文本)", + "server_already_running": "服务器已经在运行中", + "http_server_start_success": "HTTP服务器启动成功", + "http_server_start_failed": "启动HTTP服务器失败", + "http_server_stopped": "HTTP服务器已停止", + "missing_file_id": "缺少文件ID", + "read_file_failed": "读取文件失败", + "missing_file_id_or_history_id": "缺少文件ID或历史记录ID", + "history_not_found": "历史记录不存在", + "read_history_failed": "读取历史记录失败", + "select_hosts_to_export": "请选择要导出hosts文件", + "select_all": "全选", + "selected_count": "已选择", + "export_failed": "导出失败", + "nearby_devices": "附近设备", + "scan_nearby_devices": "扫描附近设备", + "no_nearby_devices": "没有发现开启共享功能的设备\n点击刷新按钮扫描附近设备", + "scanning_devices": "正在扫描附近设备...", + "sharing_enabled": "共享服务已开启", + "device_reachable": "设备可达", + "visit_device": "访问设备", + "access_denied_file_not_allowed": "访问被拒绝:文件不被允许", + "select_hosts_to_share": "请选择要分享的hosts文件", + "offline": "离线", + "scan_nearby_devices_failed": "扫描附近设备失败", + "import_remote_hosts": "导入远程hosts文件", + "refresh": "刷新", + "getting_remote_hosts": "正在获取远程hosts文件...", + "connection_failed": "连接设备失败", + "retry": "重试", + "select_all": "全选", + "no_hosts_files_found": "没有找到可用的hosts文件", + "device_no_shared_files": "该设备可能没有共享任何hosts文件", + "no_importable_content": "没有找到可导入的文件内容", + "remote_files": "个远程文件", + "show_qr_code": "显示二维码", + "port": "端口", + "hosts_diff_title": "Hosts 差异对比", + "diff_legend_added": "新增内容", + "diff_legend_deleted": "删除内容", + "diff_legend_unchanged": "未变更内容", + "diff_stats_history": "历史版本", + "diff_stats_current": "当前版本", + "diff_stats_difference": "差异", + "history_count": "历史记录", + "getting_device_info": "正在获取设备信息...", + "get_device_info_failed": "获取设备信息失败", + "basic_api": "基础 API", + "available_hosts_files_api": "可用的Hosts文件 API:", + "device_no_hosts_files": "该设备暂无可用的hosts文件", + "history_content": "历史记录内容", + "history_count_suffix": "条历史", + "get_content_prefix": "获取", + "get_content_suffix": "的内容", + "scan_qr_code_to_access": "扫描二维码访问", + "open_in_browser": "在浏览器中打开", + "show_qr_code_tooltip": "显示二维码", + "copy_url_tooltip": "复制URL", + "get_history_content_prefix": "获取历史记录", + "get_history_content_suffix": "的内容", + "more_history_records": "... 还有", + "more_history_records_suffix": "条历史记录", + "view_diff": "查看差异", + "history_version": "历史版本", + "current_version": "当前版本", + "unable_to_read_history_file": "无法读取历史文件", + "diff_legend_description": "差异说明", + "diff_legend_ok": "知道了", + "current_line": "当前行:", + "total_lines": "总行数:", + "unable_to_open": "无法打开 {host}", + "check_for_updates": "检查更新", + "report_issue": "反馈问题" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 8669ead..fcc91a9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,83 +1,18 @@ import 'dart:io'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:bloc/bloc.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:hosts/model/global_settings.dart'; -import 'package:hosts/model/simple_host_file.dart'; -import 'package:hosts/page/home_page.dart'; -import 'package:hosts/page/simple_home_page.dart'; -import 'package:hosts/theme.dart'; -import 'package:hosts/util/file_manager.dart'; -import 'package:hosts/util/settings_manager.dart'; +import 'package:hosts/app.dart'; +import 'package:hosts/host_observer.dart'; void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + Bloc.observer = const HostObserver(); + final Iterable files = args.where((path) => File(path).existsSync()); if (files.isNotEmpty) { - runApp(MyApp(filePath: files.first)); + runApp(HostsApp(files.first)); } else { - runApp(const MyApp(filePath: "")); - } -} - -class MyApp extends StatelessWidget { - final String filePath; - - const MyApp({super.key, required this.filePath}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - onGenerateTitle: (context) => AppLocalizations.of(context)!.app_name, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData( - brightness: Brightness.light, - colorScheme: MaterialTheme.lightScheme(), - useMaterial3: true, - ), - darkTheme: ThemeData( - brightness: Brightness.dark, - colorScheme: MaterialTheme.darkScheme(), - useMaterial3: true, - ), - themeMode: ThemeMode.system, - home: platformSpecificWidget(context), - ); - } - - Widget platformSpecificWidget(BuildContext context) { - GlobalSettings().isSimple = kIsWeb || filePath.isNotEmpty; - if (GlobalSettings().isSimple) { - return SimpleHomePage(filePath: filePath); - } else { - return FutureBuilder( - future: initializeApp(context), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } else { - return const HomePage(); - } - }, - ); - } - } - - Future initializeApp(BuildContext context) async { - SettingsManager settingsManager = SettingsManager(); - FileManager fileManager = FileManager(); - bool firstOpenApp = await settingsManager.getBool(settingKeyFirstOpenApp); - if (!firstOpenApp) { - const String fileName = "system"; - await fileManager.createHosts(fileName); - await settingsManager.setList(settingKeyHostConfigs, - [SimpleHostFile(fileName: fileName, remark: "")]); - await settingsManager.setString(settingKeyUseHostFile, fileName); - File(FileManager.systemHostFilePath) - .copy(await fileManager.getHostsFilePath(fileName)); - settingsManager.setBool(settingKeyFirstOpenApp, true); - } + runApp(HostsApp("")); } } diff --git a/lib/model/global_settings.dart b/lib/model/global_settings.dart index 9e4d516..e551297 100644 --- a/lib/model/global_settings.dart +++ b/lib/model/global_settings.dart @@ -7,5 +7,5 @@ class GlobalSettings { GlobalSettings._internal(); - bool isSimple = false; + String? filePath; } diff --git a/lib/model/host_file.dart b/lib/model/host_file.dart index 40608b6..f90bb99 100644 --- a/lib/model/host_file.dart +++ b/lib/model/host_file.dart @@ -34,10 +34,14 @@ class HostsModel { return ""; } - return "$text${isUse ? "" : "# "}$host ${hosts.join(" ")} ${config.isNotEmpty ? '# - config ${json.encode(config)}' : ''}"; + return "$text${toHostString()}"; } - filter(String searchQuery) { + String toHostString() { + return "${isUse ? "" : "# "}$host ${hosts.join(" ")} ${config.isNotEmpty ? '# - config ${json.encode(config)}' : ''}"; + } + + bool filter(String searchQuery) { if (searchQuery.isEmpty) return true; return host.contains(searchQuery) || description.contains(searchQuery) || @@ -64,6 +68,64 @@ class HostsModel { descLine: descLine ?? this.descLine, ); } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is HostsModel && + other.host == host && + other.isUse == isUse && + other.description == description && + _listEquals(other.hosts, hosts) && + _mapEquals(other.config, config); + } + + @override + int get hashCode { + return host.hashCode ^ + isUse.hashCode ^ + description.hashCode ^ + _listHash(hosts) ^ + _mapHash(config); + } + + static bool _listEquals(List? list1, List? list2) { + if (identical(list1, list2)) return true; + if (list1 == null || list2 == null) return false; + if (list1.length != list2.length) return false; + for (int i = 0; i < list1.length; i++) { + if (list1[i] != list2[i]) return false; + } + return true; + } + + static int _listHash(List list) { + int hash = 0; + for (var item in list) { + hash ^= item.hashCode; + } + return hash; + } + + static bool _mapEquals( + Map? map1, Map? map2) { + if (identical(map1, map2)) return true; + if (map1 == null || map2 == null) return false; + if (map1.length != map2.length) return false; + for (var key in map1.keys) { + if (map1[key] != map2[key]) return false; + } + return true; + } + + static int _mapHash(Map map) { + int hash = 0; + for (var key in map.keys) { + hash ^= key.hashCode ^ map[key].hashCode; + } + return hash; + } } class HostsFile { diff --git a/lib/page/home_base_page.dart b/lib/page/home_base_page.dart deleted file mode 100644 index e3862c8..0000000 --- a/lib/page/home_base_page.dart +++ /dev/null @@ -1,505 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:hosts/enums.dart'; -import 'package:hosts/model/host_file.dart'; -import 'package:hosts/model/simple_host_file.dart'; -import 'package:hosts/page/host_page.dart'; -import 'package:hosts/util/file_manager.dart'; -import 'package:hosts/widget/dialog/link_dialog.dart'; -import 'package:hosts/widget/error/error_empty.dart'; -import 'package:hosts/widget/host_list.dart'; -import 'package:hosts/widget/host_table.dart'; -import 'package:hosts/widget/host_text_editing_controller.dart'; -import 'package:hosts/widget/row_line_widget.dart'; -import 'package:hosts/widget/snakbar.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -abstract class BaseHomePage extends StatefulWidget { - const BaseHomePage({super.key}); - - @override - BaseHomePageState createState(); -} - -abstract class BaseHomePageState extends State { - // 选中的主机列表 - final List selectHosts = []; - - // 过滤后的主机列表 - final List filterHosts = []; - HostsFile hostsFile = HostsFile("", ""); - EditMode editMode = EditMode.Table; - AdvancedSettingsEnum advancedSettingsEnum = AdvancedSettingsEnum.Close; - String searchText = ""; - Map sortConfig = { - "host": null, - "isUse": null, - "hosts": null, - "description": null, - }; - SimpleHostFileHistory? selectHistory; - HostTextEditingController textEditingController = HostTextEditingController(); - final FocusNode _focusNode = FocusNode(); - bool isControl = false; - - final ScrollController _scrollController = ScrollController(); - final ScrollController _textScrollController = ScrollController(); - - final GlobalKey _textFieldContainerKey = GlobalKey(); - - @override - void initState() { - _textScrollController.addListener(() { - if (_textScrollController.hasClients) { - _scrollController.jumpTo(_textScrollController.offset); - } - }); - super.initState(); - } - - @override - void dispose() { - super.dispose(); - _scrollController.dispose(); - textEditingController.dispose(); - } - - /// 处理打开文件的操作 - /// [content] 是文件的内容 - onOpenFile(String content) { - setState(() { - if (editMode == EditMode.Table) { - hostsFile.formString(content); - hostsFile.defaultContent = content; - hostsFile.isUpdateHost(); - syncFilterHosts(); - } else { - textEditingController.dispose(); - - textEditingController = HostTextEditingController() - ..text = content - ..addListener(() { - setState(() { - hostsFile.isUpdateHostWithText(textEditingController.text); - }); - }); - } - }); - } - - /// 撤销上一次的主机操作 - undoHost() { - setState(() { - hostsFile.undoHost(); - textEditingController.value = - TextEditingValue(text: hostsFile.toString()); - syncFilterHosts(); - }); - } - - /// 处理搜索文本变化 - /// [value] 是新的搜索文本 - onSearchChanged(String value) { - setState(() { - searchText = value; - syncFilterHosts(); - }); - } - - /// 切换高级设置的状态 - /// [value] 是新的高级设置状态 - onSwitchAdvancedSettings(AdvancedSettingsEnum value) { - setState(() { - advancedSettingsEnum = value; - }); - } - - /// 切换编辑模式 - /// [value] 是新的编辑模式 - onSwitchMode(EditMode value) { - setState(() { - if (editMode == EditMode.Text) { - editMode = EditMode.Table; - hostsFile.formString(textEditingController.text); - syncFilterHosts(); - } else { - editMode = EditMode.Text; - textEditingController.value = - TextEditingValue(text: hostsFile.toString()); - } - }); - } - - /// 处理删除操作 - onDeletePressed() { - deleteMultiple( - context, - selectHosts.map((item) => item.host).toList(), - () => setState(() { - hostsFile.deleteMultiple(selectHosts); - syncFilterHosts(); - }), - ); - } - - /// 处理全选状态变化 - /// [value] 是全选的状态 - onCheckedAllChanged(bool? value) { - setState(() { - selectHosts.clear(); - if (value ?? false) { - selectHosts.addAll(hostsFile.hosts); - } - }); - } - - /// 处理排序配置变化 - /// [value] 是新的排序配置 - onSortConfChanged(Map value) { - setState(() { - sortConfig = value; - syncFilterHosts(); - }); - } - - /// 切换主机的使用状态 - /// [value] 是新的使用状态 - onSwitchHosts(bool value) { - setState(() { - for (var host in selectHosts) { - host.isUse = value; - } - // syncFilterHosts(); - }); - } - - /// 处理单个主机的选中状态 - /// [index] 是主机的索引 - /// [host] 是被选中的主机 - onChecked(int index, HostsModel host) { - setState(() { - if (selectHosts.contains(host)) { - selectHosts.remove(host); - } else { - selectHosts.add(host); - } - }); - } - - /// 处理主机链接的操作 - /// [index] 是主机的索引 - /// [host] 是被链接的主机 - onLink(int index, HostsModel host) async { - final Map>? result = - await linkDialog(context, hostsFile.hosts, host); - if (result == null) return; - setState(() { - host.config = result; - hostsFile.updateHost(index, host); - }); - } - - /// 处理主机编辑操作 - /// [index] 是主机的索引 - /// [host] 是被编辑的主机 - onEdit(int index, HostsModel host) async { - List? hostsModels = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => HostPage(hostModel: host), - ), - ); - if (hostsModels == null) return; - setState(() { - hostsFile.updateHost( - index, - hostsFile.hosts[index].withCopy( - host: hostsModels.first.host, - isUse: hostsModels.first.isUse, - description: hostsModels.first.description, - hosts: hostsModels.first.hosts, - hostLine: hostsModels.first.hostLine, - descLine: hostsModels.first.descLine, - )); - syncFilterHosts(); - }); - } - - /// 处理主机删除操作 - /// [hosts] 是要删除的主机列表 - onDelete(List hosts) { - deleteMultiple( - context, - hosts.map((item) => item.host).toList(), - () => setState(() { - hostsFile.deleteMultiple(hosts); - syncFilterHosts(); - }), - ); - } - - /// 切换主机的使用状态 - /// [hosts] 是要切换状态的主机列表 - onToggleUse(List hosts) { - setState(() { - hostsFile.updateHostUseState(hosts); - syncFilterHosts(); - }); - } - - /// 同步变更的 Hosts 文件 - void syncFilterHosts() { - selectHosts.clear(); - filterHosts.clear(); - filterHosts.addAll(hostsFile.filterHosts(searchText, sortConfig)); - } - - /// 构建浮动操作按钮 - /// [context] 是构建按钮的上下文 - FloatingActionButton? buildFloatingActionButton(BuildContext context) { - if (editMode == EditMode.Table) { - return FloatingActionButton( - onPressed: () async { - List? hostsModels = await Navigator.of(context) - .push(MaterialPageRoute(builder: (context) => const HostPage())); - if (hostsModels == null) return; - setState(() { - for (HostsModel hostsModel in hostsModels) { - hostsFile.addHost(hostsModel); - } - syncFilterHosts(); - }); - }, - child: const Icon(Icons.add), - ); - } - return null; - } - - /// 构建主机表格或文本编辑器 - /// [filterHosts] 是过滤后的主机列表 - Widget buildHostTableOrTextEdit(List filterHosts) { - if (editMode == EditMode.Text) { - return Expanded( - child: Column( - children: [ - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RowLineWidget( - textEditingController: textEditingController, - context: context, - textFieldContainerKey: _textFieldContainerKey, - scrollController: _scrollController, - ), - Expanded( - key: _textFieldContainerKey, - child: KeyboardListener( - focusNode: _focusNode, - onKeyEvent: (event) { - if ([ - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.controlRight - ].contains(event.logicalKey)) { - if (isControl) { - isControl = false; - } else { - isControl = true; - } - } - if (event.logicalKey == LogicalKeyboardKey.slash && - isControl && - event is KeyDownEvent) { - textEditingController - .updateUseStatus(textEditingController.selection); - } - - if (event.logicalKey == LogicalKeyboardKey.keyS && - isControl && - event is KeyDownEvent && - !hostsFile.isSave) { - onKeySaveChange(); - } - }, - child: TextField( - controller: textEditingController, - scrollController: _textScrollController, - maxLines: double.maxFinite.toInt(), - decoration: - const InputDecoration(border: InputBorder.none), - ), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Row( - children: [ - Text( - "当前行:${textEditingController.countNewlines(textEditingController.text.substring(0, textEditingController.selection.start > 0 ? textEditingController.selection.start : 0)) + 1}", - ), - const SizedBox( - width: 8, - ), - Text( - "总行数:${textEditingController.countNewlines(textEditingController.text) + 1}"), - ], - ), - ) - ], - ), - ); - } - - if (filterHosts.isEmpty) { - return Expanded( - child: Container( - alignment: Alignment.center, - width: double.maxFinite, - height: double.maxFinite, - child: const ErrorEmpty(), - ), - ); - } - - if (MediaQuery.of(context).size.width >= 1000) { - return Expanded( - child: HostTable( - hosts: filterHosts, - selectHosts: selectHosts, - onChecked: onChecked, - onLink: onLink, - onEdit: onEdit, - onDelete: onDelete, - onToggleUse: onToggleUse, - onLaunchUrl: (url) { - // Uncomment and implement the URL launching logic if needed - // if (!await launchUrl(Uri.https(url))) { - // throw Exception('Could not launch $url'); - // } - }, - ), - ); - } else { - return Expanded( - child: HostList( - hosts: filterHosts, - selectHosts: selectHosts, - onChecked: onChecked, - onLink: onLink, - onEdit: onEdit, - onDelete: onDelete, - onToggleUse: onToggleUse, - onLaunchUrl: (url) { - // Uncomment and implement the URL launching logic if needed - // if (!await launchUrl(Uri.https(url))) { - // throw Exception('Could not launch $url'); - // } - }, - ), - ); - } - } - - Future saveHost(String filePath, String hostContent) async { - if (kIsWeb) { - final String tempContent = hostContent.replaceAll("\"", "\\\""); - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text("保存"), - content: SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: SelectableText(hostContent), - ), - actions: [ - TextButton( - onPressed: () => writeClipboard( - 'echo "$tempContent" > /etc/hosts', - tempContent, - context, - ), - child: const Text("Linux(echo)")), - TextButton( - onPressed: () { - final String systemHostPath = p.joinAll([ - "C:", - "Windows", - "System32", - "drivers", - "etc", - "hosts" - ]); - final String content = hostContent - .split("\n") - .map((item) => 'echo $item') - .join("\n"); - writeClipboard( - '(\n$content\n) > $systemHostPath', - hostContent, - context, - ); - }, - child: const Text("Windows(echo)")), - TextButton( - onPressed: () => writeClipboard( - 'echo "$tempContent" > /etc/hosts', - tempContent, - context, - ), - child: const Text("MacOS(echo)")), - ], - )); - return true; - } - - final File file = File(filePath); - try { - await file.writeAsString(hostContent); - } catch (e) { - try { - final Directory cacheDirectory = await getApplicationCacheDirectory(); - final File cacheFile = File(p.join(cacheDirectory.path, 'hosts')); - await cacheFile.writeAsString(hostContent); - - await FileManager() - .writeFileWithAdminPrivileges(cacheFile.path, filePath); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)!.error_save_fail))); - return false; - } - } - - setState(() { - hostsFile.defaultContent = hostContent; - hostsFile.isUpdateHost(); - }); - return true; - } - - void writeClipboard( - String hostContent, String defaultContent, BuildContext context) { - Clipboard.setData(ClipboardData(text: hostContent)).then((_) { - setState(() { - hostsFile.defaultContent = defaultContent; - hostsFile.isUpdateHost(); - Navigator.pop(context); - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.copy_to_tip), - ), - ); - }); - } - - void onKeySaveChange(); -} diff --git a/lib/page/home_page.dart b/lib/page/home_page.dart deleted file mode 100644 index 3869805..0000000 --- a/lib/page/home_page.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:hosts/enums.dart'; -import 'package:hosts/model/host_file.dart'; -import 'package:hosts/model/simple_host_file.dart'; -import 'package:hosts/page/home_base_page.dart'; -import 'package:hosts/util/file_manager.dart'; -import 'package:hosts/util/settings_manager.dart'; -import 'package:hosts/widget/app_bar/home_app_bar.dart'; -import 'package:hosts/widget/home_drawer.dart'; -import 'package:hosts/widget/host_text_editing_controller.dart'; - -class HomePage extends BaseHomePage { - const HomePage({super.key}); - - @override - _HomePageState createState() => _HomePageState(); // 返回 _HomePageState -} - -class _HomePageState extends BaseHomePageState { - final GlobalKey _scaffoldKey = GlobalKey(); - final SettingsManager _settingsManager = SettingsManager(); - final FileManager _fileManager = FileManager(); - - @override - void initState() { - textEditingController.addListener(() { - setState(() { - hostsFile.isUpdateHostWithText(textEditingController.text); - }); - }); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - key: _scaffoldKey, - floatingActionButton: buildFloatingActionButton(context), - drawer: MediaQuery.of(context).size.width < 600 - ? buildHomeDrawer(context) - : null, - body: Row( - children: [ - if (advancedSettingsEnum == AdvancedSettingsEnum.Close && - MediaQuery.of(context).size.width > 600) - buildHomeDrawer(context), - Expanded( - child: Column( - children: [ - HomeAppBar( - isSave: hostsFile.isSave, - onOpenFile: onOpenFile, - undoHost: undoHost, - searchText: searchText, - onSearchChanged: onSearchChanged, - advancedSettingsEnum: advancedSettingsEnum, - onSwitchAdvancedSettings: (AdvancedSettingsEnum value) { - setState(() { - advancedSettingsEnum = value; - }); - _scaffoldKey.currentState?.openDrawer(); - }, - editMode: editMode, - onSwitchMode: onSwitchMode, - hosts: selectHosts, - sortConfig: sortConfig, - onDeletePressed: onDeletePressed, - isCheckedAll: hostsFile.hosts.length == selectHosts.length, - onCheckedAllChanged: onCheckedAllChanged, - onSortConfChanged: onSortConfChanged, - selectHistory: selectHistory, - history: hostsFile.history, - onSwitchHosts: onSwitchHosts, - onHistoryChanged: (history) async { - List resultHistory = - await _fileManager.getHistory(hostsFile.fileId); - setState(() { - if (history != null) { - selectHistory = history; - hostsFile.setHistory(history.path).then((value) { - if (editMode != EditMode.Text) return; - updateTextEditingController(); - }); - } - hostsFile.history = resultHistory; - syncFilterHosts(); - }); - }, - ), - if (!hostsFile.isSave) - FutureBuilder( - future: saveTipMessage(context), - builder: (BuildContext context, - AsyncSnapshot snapshot) { - if (snapshot.hasData) { - return snapshot.data!; - } - return const SizedBox(); - }), - buildHostTableOrTextEdit(filterHosts) - ], - ), - ), - ], - ), - ); - } - - HomeDrawer buildHomeDrawer(BuildContext context) { - return HomeDrawer( - isSave: hostsFile.isSave, - onChanged: (String value, String fileId) async { - if (await _settingsManager.getString(settingKeyUseHostFile) == fileId) { - if (!await _fileManager.areFilesEqual(fileId)) { - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(AppLocalizations.of(context)!.warning), - content: - Text(AppLocalizations.of(context)!.warning_different), - actions: [ - TextButton( - onPressed: () async { - if (await saveHost(FileManager.systemHostFilePath, - hostsFile.toString())) { - Navigator.of(context).pop(); - } - }, - child: Text(AppLocalizations.of(context)! - .warning_different_covering_system), - ), - TextButton( - onPressed: () async { - setState(() { - hostsFile.formString( - File(FileManager.systemHostFilePath) - .readAsStringSync()); - hostsFile.save(true); - Navigator.of(context).pop(); - }); - }, - child: Text(AppLocalizations.of(context)! - .warning_different_covering_current), - ), - ], - ); - }); - } - } - setState(() { - hostsFile = HostsFile(value, fileId); - if (editMode == EditMode.Text) { - updateTextEditingController(); - } else { - syncFilterHosts(); - } - }); - }, - onClickUse: (hostContent) async { - return await saveHost(FileManager.systemHostFilePath, hostContent); - }, - ); - } - - void updateTextEditingController() { - textEditingController.dispose(); - - textEditingController = HostTextEditingController() - ..text = hostsFile.toString() - ..addListener(() { - setState(() { - hostsFile.isUpdateHostWithText(textEditingController.text); - }); - }); - } - - Future saveTipMessage(BuildContext context) async { - final bool isUseFile = hostsFile.fileId == - await _settingsManager.getString(settingKeyUseHostFile); - - final String updateSaveTip = - AppLocalizations.of(context)!.error_not_update_save_tip; - final String updateSavePermissionTip = isUseFile - ? '\n${AppLocalizations.of(context)!.error_not_update_save_permission_tip}' - : ''; - return MaterialBanner( - content: Text("$updateSaveTip$updateSavePermissionTip"), - leading: const Icon(Icons.error_outline), - actions: [ - TextButton( - onPressed: () => onKeySaveChange(true), - child: Text(AppLocalizations.of(context)!.save_create_history), - ), - TextButton( - onPressed: onKeySaveChange, - child: Text(AppLocalizations.of(context)!.save), - ), - ], - ); - } - - @override - void onKeySaveChange([bool isHistory = false]) async { - if (editMode == EditMode.Text) { - hostsFile.formString(textEditingController.text); - } - final bool isUseFile = hostsFile.fileId == - await _settingsManager.getString(settingKeyUseHostFile); - if (isUseFile) { - if (!await saveHost( - FileManager.systemHostFilePath, hostsFile.toString())) { - return; - } - } - setState(() { - hostsFile.save(isHistory); - }); - } -} diff --git a/lib/page/simple_home_page.dart b/lib/page/simple_home_page.dart deleted file mode 100644 index ec7b24e..0000000 --- a/lib/page/simple_home_page.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:hosts/enums.dart'; -import 'package:hosts/page/home_base_page.dart'; -import 'package:hosts/widget/app_bar/home_app_bar.dart'; - -class SimpleHomePage extends BaseHomePage { - final String filePath; - - const SimpleHomePage({super.key, required this.filePath}); - - @override - _SimpleHomePageState createState() => _SimpleHomePageState(); -} - -class _SimpleHomePageState extends BaseHomePageState { - @override - void initState() { - if (widget.filePath.isNotEmpty) { - final String fileContent = File(widget.filePath).readAsStringSync(); - setState(() { - hostsFile.formString(fileContent); - hostsFile.defaultContent = fileContent; - filterHosts.clear(); - filterHosts.addAll(hostsFile.filterHosts(searchText, sortConfig)); - }); - } - - if (kIsWeb) { - setState(() { - hostsFile.formString(""); - hostsFile.defaultContent = ""; - }); - } - - textEditingController.addListener(() { - setState(() { - hostsFile.isUpdateHostWithText(textEditingController.text); - }); - }); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - floatingActionButton: buildFloatingActionButton(context), - body: Column( - children: [ - HomeAppBar( - isSave: hostsFile.isSave, - onOpenFile: onOpenFile, - undoHost: undoHost, - searchText: searchText, - onSearchChanged: onSearchChanged, - advancedSettingsEnum: advancedSettingsEnum, - onSwitchAdvancedSettings: onSwitchAdvancedSettings, - editMode: editMode, - onSwitchMode: onSwitchMode, - hosts: selectHosts, - sortConfig: sortConfig, - onDeletePressed: onDeletePressed, - isCheckedAll: hostsFile.hosts.length == selectHosts.length, - onCheckedAllChanged: onCheckedAllChanged, - onSortConfChanged: onSortConfChanged, - selectHistory: selectHistory, - history: hostsFile.history, - onSwitchHosts: onSwitchHosts, - onHistoryChanged: (history) {}, - ), - if (!hostsFile.isSave) - FutureBuilder( - future: saveTipMessage(context), - builder: - (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.hasData) { - return snapshot.data!; - } - return const SizedBox(); - }), - buildHostTableOrTextEdit(filterHosts) - ], - ), - ); - } - - Future saveTipMessage(BuildContext context) async { - return MaterialBanner( - content: Text(AppLocalizations.of(context)!.error_not_update_save_tip), - leading: const Icon(Icons.error_outline), - actions: [ - TextButton( - onPressed: onKeySaveChange, - child: Text(AppLocalizations.of(context)!.save), - ), - ], - ); - } - - @override - void onKeySaveChange() async { - if (editMode == EditMode.Text) { - hostsFile.formString(textEditingController.text); - } - saveHost(widget.filePath, hostsFile.toString()); - } -} diff --git a/lib/server/bloc/nearby_devices_cubit.dart b/lib/server/bloc/nearby_devices_cubit.dart new file mode 100644 index 0000000..e68b6fe --- /dev/null +++ b/lib/server/bloc/nearby_devices_cubit.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:hosts/util/nearby_devices_scanner.dart'; + +part 'nearby_devices_state.dart'; + +/// 附近设备 Cubit +class NearbyDevicesCubit extends Cubit { + NearbyDevicesCubit() : super(const NearbyDevicesInitial(NearbyDevicesStateData())); + + /// 扫描附近设备 + Future scanNearbyDevices() async { + try { + if (isClosed) return; + + emit(NearbyDevicesScanning( + state.data.copyWith( + isScanning: true, + errorMessage: null, + devices: [], // 清空现有设备列表 + ), + )); + + // 开始实时扫描 + await NearbyDevicesScanner.scanNearbyDevicesRealTime( + onDeviceFound: (device) { + // 实时更新设备列表 + if (!isClosed) { + onDeviceFound(device); + } + }, + ); + + if (!isClosed) { + emit(NearbyDevicesInitial( + state.data.copyWith( + isScanning: false, + ), + )); + } + } catch (e) { + if (!isClosed) { + emit(NearbyDevicesError( + state.data.copyWith( + isScanning: false, + errorMessage: 'Failed to scan nearby devices: $e', + ), + )); + } + } + } + + /// 设备发现处理 + void onDeviceFound(NearbyDevice device) { + if (isClosed) return; + + final updatedDevices = List.from(state.devices); + + // 避免重复添加同一IP的设备 + final existingIndex = updatedDevices.indexWhere((d) => d.ip == device.ip); + if (existingIndex >= 0) { + updatedDevices[existingIndex] = device; + } else { + updatedDevices.add(device); + } + + emit(NearbyDevicesDeviceFound( + state.data.copyWith(devices: updatedDevices), + )); + } + + /// 获取设备状态摘要(供其他组件使用) + Map getDevicesSummary() { + return { + 'total': state.data.totalDevicesCount, + 'online': state.data.onlineDevicesCount, + 'offline': state.data.totalDevicesCount - state.data.onlineDevicesCount, + }; + } + + /// 清除消息 + void clearMessages() { + if (isClosed) return; + + emit(NearbyDevicesInitial( + state.data.copyWith( + errorMessage: null, + ), + )); + } +} \ No newline at end of file diff --git a/lib/server/bloc/nearby_devices_state.dart b/lib/server/bloc/nearby_devices_state.dart new file mode 100644 index 0000000..31a6fa1 --- /dev/null +++ b/lib/server/bloc/nearby_devices_state.dart @@ -0,0 +1,112 @@ +part of 'nearby_devices_cubit.dart'; + +/// 附近设备状态数据类 +/// 保存所有与附近设备相关的状态属性 +class NearbyDevicesStateData { + /// 设备列表 + final List devices; + + /// 是否正在扫描 + final bool isScanning; + + /// 是否正在加载 + final bool isLoading; + + /// 错误信息 + final String? errorMessage; + + /// 构造函数 + const NearbyDevicesStateData({ + this.devices = const [], + this.isScanning = false, + this.isLoading = false, + this.errorMessage, + }); + + /// 获取在线设备数量 + int get onlineDevicesCount => devices.where((device) => device.isReachable && device.hasSharing).length; + + /// 获取总设备数量 + int get totalDevicesCount => devices.length; + + /// 获取在线设备列表 + List get onlineDevices => devices.where((device) => device.isReachable && device.hasSharing).toList(); + + /// 获取离线设备列表 + List get offlineDevices => devices.where((device) => !device.isReachable || !device.hasSharing).toList(); + + /// 复制方法 + /// 用于基于当前状态创建新状态 + NearbyDevicesStateData copyWith({ + List? devices, + bool? isScanning, + bool? isLoading, + String? errorMessage, + }) { + return NearbyDevicesStateData( + devices: devices ?? this.devices, + isScanning: isScanning ?? this.isScanning, + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + ); + } +} + +/// 附近设备状态基类 +/// 使用密封类设计模式限制状态类型 +/// 不可变状态基类 +@immutable +sealed class NearbyDevicesState { + final NearbyDevicesStateData data; + + const NearbyDevicesState(this.data); + + /// 获取在线设备数量 + int get onlineDevicesCount => data.onlineDevicesCount; + + /// 获取总设备数量 + int get totalDevicesCount => data.totalDevicesCount; + + /// 获取在线设备列表 + List get onlineDevices => data.onlineDevices; + + /// 获取离线设备列表 + List get offlineDevices => data.offlineDevices; + + /// 设备列表 + List get devices => data.devices; + + /// 是否正在扫描 + bool get isScanning => data.isScanning; + + /// 是否正在加载 + bool get isLoading => data.isLoading; + + /// 错误信息 + String? get errorMessage => data.errorMessage; +} + +/// 初始状态 +class NearbyDevicesInitial extends NearbyDevicesState { + const NearbyDevicesInitial(super.data); +} + +/// 扫描中状态 +class NearbyDevicesScanning extends NearbyDevicesState { + const NearbyDevicesScanning(super.data); +} + +/// 加载中状态 +class NearbyDevicesLoading extends NearbyDevicesState { + const NearbyDevicesLoading(super.data); +} + +/// 设备发现状态 +class NearbyDevicesDeviceFound extends NearbyDevicesState { + const NearbyDevicesDeviceFound(super.data); +} + +/// 错误状态 +class NearbyDevicesError extends NearbyDevicesState { + const NearbyDevicesError(super.data); +} \ No newline at end of file diff --git a/lib/server/bloc/server_settings_bloc.dart b/lib/server/bloc/server_settings_bloc.dart new file mode 100644 index 0000000..a0f1905 --- /dev/null +++ b/lib/server/bloc/server_settings_bloc.dart @@ -0,0 +1,412 @@ +import 'dart:io'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:hosts/server/bloc/server_settings_event.dart'; +import 'package:hosts/server/bloc/server_settings_state.dart'; +import 'package:hosts/server/server_manager.dart'; +import 'package:hosts/util/settings_manager.dart'; +import 'package:network_info_plus/network_info_plus.dart'; + +/// 服务器设置页面 BLoC +class ServerSettingsBloc extends Bloc { + ServerSettingsBloc() : super(const ServerSettingsState()) { + on(_onLoadServerSettings); + on(_onLoadNetworkInterfaces); + on(_onToggleServerStatus); + on(_onStartServer); + on(_onStopServer); + on(_onRefreshServerStatus); + on(_onUpdateAutoStartSettings); + } + + final ServerManager _serverManager = ServerManager(); + final SettingsManager _settingsManager = SettingsManager(); + + /// 加载服务器设置 + Future _onLoadServerSettings( + LoadServerSettings event, + Emitter emit, + ) async { + try { + emit(state.copyWith(isLoading: true, errorMessage: null)); + + final status = await _serverManager.getServerStatus(); + final isAutoStartEnabled = await _settingsManager.getBool(settingKeyAutoStartEnabled); + + emit(state.copyWith( + isLoading: false, + serverStatus: status, + isServerEnabled: status['isEnabled'] ?? false, + isAutoStartEnabled: isAutoStartEnabled, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + errorMessage: '加载服务器设置失败: $e', + )); + } + } + + /// 加载网络接口 + Future _onLoadNetworkInterfaces( + LoadNetworkInterfaces event, + Emitter emit, + ) async { + try { + final networkInterfaces = await _getNetworkInterfaces(); + emit(state.copyWith(networkInterfaces: networkInterfaces)); + } catch (e) { + emit(state.copyWith( + errorMessage: '获取网络接口失败: $e', + )); + } + } + + /// 切换服务器状态 + Future _onToggleServerStatus( + ToggleServerStatus event, + Emitter emit, + ) async { + try { + emit(state.copyWith(isLoading: true, errorMessage: null)); + + bool success; + if (state.isServerEnabled) { + await _serverManager.stopServer(); + success = true; + } else { + success = await _serverManager.startServer(); + } + + if (success) { + // 重新加载服务器状态 + final status = await _serverManager.getServerStatus(); + emit(state.copyWith( + isLoading: false, + serverStatus: status, + isServerEnabled: status['isEnabled'] ?? false, + )); + } else { + emit(state.copyWith( + isLoading: false, + errorMessage: '操作失败', + )); + } + } catch (e) { + emit(state.copyWith( + isLoading: false, + errorMessage: '操作失败: $e', + )); + } + } + + /// 启动服务器 + Future _onStartServer( + StartServer event, + Emitter emit, + ) async { + try { + emit(state.copyWith(isLoading: true, errorMessage: null)); + + // 将选中的hosts文件名传递给服务器管理器 + final selectedHostNames = event.selectedHosts?.map((host) => host.fileName).toList(); + final success = await _serverManager.startServer(allowedHostFiles: selectedHostNames); + + if (success) { + // 重新加载服务器状态 + final status = await _serverManager.getServerStatus(); + emit(state.copyWith( + isLoading: false, + serverStatus: status, + isServerEnabled: status['isEnabled'] ?? false, + )); + } else { + emit(state.copyWith( + isLoading: false, + errorMessage: '启动服务器失败', + )); + } + } catch (e) { + emit(state.copyWith( + isLoading: false, + errorMessage: '启动服务器失败: $e', + )); + } + } + + /// 停止服务器 + Future _onStopServer( + StopServer event, + Emitter emit, + ) async { + try { + emit(state.copyWith(isLoading: true, errorMessage: null)); + + await _serverManager.stopServer(); + + // 重新加载服务器状态 + final status = await _serverManager.getServerStatus(); + emit(state.copyWith( + isLoading: false, + serverStatus: status, + isServerEnabled: status['isEnabled'] ?? false, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + errorMessage: '停止服务器失败: $e', + )); + } + } + + /// 刷新服务器状态 + Future _onRefreshServerStatus( + RefreshServerStatus event, + Emitter emit, + ) async { + add(LoadServerSettings()); + } + + /// 更新自动启动设置 + Future _onUpdateAutoStartSettings( + UpdateAutoStartSettings event, + Emitter emit, + ) async { + try { + emit(state.copyWith(isLoading: true, errorMessage: null)); + + // 保存自动启动开关状态 + await _settingsManager.setBool(settingKeyAutoStartEnabled, event.enabled); + + // 如果启用自动启动且提供了hosts文件列表,则保存 + if (event.enabled && event.selectedHosts != null) { + final hostsList = event.selectedHosts!.map((host) => host.toJson()).toList(); + await _settingsManager.setList(settingKeyAutoStartHosts, hostsList); + } else if (!event.enabled) { + // 如果禁用自动启动,清除保存的hosts文件列表 + await _settingsManager.remove(settingKeyAutoStartHosts); + } + + emit(state.copyWith( + isLoading: false, + isAutoStartEnabled: event.enabled, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + errorMessage: '更新自动启动设置失败: $e', + )); + } + } + + /// 获取所有网络接口 + Future>> _getNetworkInterfaces() async { + final List> networkList = []; + final info = NetworkInfo(); + + try { + print('开始获取网络信息...'); + + // 1. 使用 network_info_plus 获取WiFi信息 + try { + final wifiIP = await info.getWifiIP(); + final wifiName = await info.getWifiName(); + final wifiBSSID = await info.getWifiBSSID(); + + print('WiFi IP: $wifiIP'); + print('WiFi Name: $wifiName'); + print('WiFi BSSID: $wifiBSSID'); + + if (wifiIP != null && wifiIP.isNotEmpty && wifiIP != '0.0.0.0') { + networkList.add({ + 'name': 'WiFi', + 'address': wifiIP, + 'description': + 'WiFi${wifiName != null ? ' ($wifiName)' : ''} (IPv4) Private', + 'icon': Icons.wifi.codePoint.toString(), + 'type': 'ipv4', + 'isMain': 'true', + }); + } + } catch (e) { + print('获取WiFi信息失败: $e'); + } + + // 2. 使用传统方法作为补充 + try { + final interfaces = await NetworkInterface.list( + includeLoopback: true, + includeLinkLocal: false, + type: InternetAddressType.any, + ); + + print('发现的系统网络接口数量: ${interfaces.length}'); + + // 收集所有已知IP,避免重复 + final knownIPs = networkList.map((e) => e['address']).toSet(); + + for (final interface in interfaces) { + print('接口: ${interface.name}, 地址数量: ${interface.addresses.length}'); + + if (interface.addresses.isNotEmpty) { + for (final address in interface.addresses) { + final ip = address.address; + + // 跳过已知的IP地址 + if (knownIPs.contains(ip)) { + continue; + } + + print(' 地址: $ip, 类型: ${address.type}'); + + // 根据接口类型和地址类型确定图标和描述 + String description; + IconData icon; + bool isMainInterface = false; + + // 更精确的接口识别 + final interfaceName = interface.name.toLowerCase(); + + if (interfaceName.contains('lo') || interfaceName == 'loopback') { + description = 'Loopback'; + icon = Icons.loop; + } else if (interfaceName.contains('wlan') || + interfaceName.contains('wifi') || + interfaceName.contains('wi-fi') || + interfaceName.startsWith('wl')) { + description = 'WiFi (${interface.name})'; + icon = Icons.wifi; + isMainInterface = true; + } else if (interfaceName.contains('eth') || + interfaceName.contains('en') || + interfaceName.startsWith('enp') || + interfaceName.startsWith('ens') || + interfaceName.startsWith('eno')) { + description = 'Ethernet (${interface.name})'; + icon = Icons.settings_ethernet; + isMainInterface = true; + } else if (interfaceName.contains('docker') || + interfaceName.contains('br-') || + interfaceName.startsWith('docker')) { + description = 'Docker (${interface.name})'; + icon = Icons.developer_board; + } else if (interfaceName.contains('vmnet') || + interfaceName.contains('vbox') || + interfaceName.contains('virtual')) { + description = 'Virtual (${interface.name})'; + icon = Icons.computer; + } else if (interfaceName.contains('tun') || + interfaceName.contains('tap')) { + description = 'VPN/Tunnel (${interface.name})'; + icon = Icons.vpn_key; + } else { + description = interface.name; + icon = Icons.device_hub; + // 如果是未知接口但有局域网IP,也标记为主要接口 + if (address.type == InternetAddressType.IPv4) { + if (ip.startsWith('192.168.') || + ip.startsWith('10.') || + ip.startsWith('172.')) { + isMainInterface = true; + } + } + } + + // 添加地址类型和网络范围标识 + if (address.type == InternetAddressType.IPv6) { + description += ' (IPv6)'; + if (ip.startsWith('fe80')) { + description += ' Link-Local'; + } + } else { + description += ' (IPv4)'; + if (ip.startsWith('192.168.')) { + description += ' Private'; + isMainInterface = true; + } else if (ip.startsWith('10.')) { + description += ' Private'; + isMainInterface = true; + } else if (ip.startsWith('172.')) { + final second = int.tryParse(ip.split('.')[1]) ?? 0; + if (second >= 16 && second <= 31) { + description += ' Private'; + isMainInterface = true; + } + } else if (ip.startsWith('127.')) { + description += ' Loopback'; + } else { + description += ' Public'; + isMainInterface = true; + } + } + + networkList.add({ + 'name': interface.name, + 'address': ip, + 'description': description, + 'icon': icon.codePoint.toString(), + 'type': + address.type == InternetAddressType.IPv4 ? 'ipv4' : 'ipv6', + 'isMain': isMainInterface.toString(), + }); + + knownIPs.add(ip); + } + } + } + } catch (e) { + print('获取系统网络接口失败: $e'); + } + + // 3. 如果都没有获取到,添加默认的localhost + if (networkList.isEmpty) { + networkList.add({ + 'name': 'localhost', + 'address': '127.0.0.1', + 'description': 'Localhost (IPv4) Loopback', + 'icon': Icons.computer.codePoint.toString(), + 'type': 'ipv4', + 'isMain': 'false', + }); + } + + // 按优先级排序:主要接口 > IPv4 > 接口名称 + networkList.sort((a, b) { + final aIsMain = a['isMain'] == 'true'; + final bIsMain = b['isMain'] == 'true'; + + if (aIsMain && !bIsMain) return -1; + if (!aIsMain && bIsMain) return 1; + + final aIsIPv4 = a['type'] == 'ipv4'; + final bIsIPv4 = b['type'] == 'ipv4'; + + if (aIsIPv4 && !bIsIPv4) return -1; + if (!aIsIPv4 && bIsIPv4) return 1; + + return a['name']!.compareTo(b['name']!); + }); + + print('最终网络接口列表: ${networkList.length}'); + for (final interface in networkList) { + print(' ${interface['description']}: ${interface['address']}'); + } + + return networkList; + } catch (e) { + print('获取网络接口失败: $e'); + // 最后的备用方案 + return [ + { + 'name': 'localhost', + 'address': '127.0.0.1', + 'description': 'Localhost (IPv4) Loopback', + 'icon': Icons.computer.codePoint.toString(), + 'type': 'ipv4', + 'isMain': 'false', + } + ]; + } + } +} \ No newline at end of file diff --git a/lib/server/bloc/server_settings_event.dart b/lib/server/bloc/server_settings_event.dart new file mode 100644 index 0000000..a8da381 --- /dev/null +++ b/lib/server/bloc/server_settings_event.dart @@ -0,0 +1,46 @@ +import 'package:equatable/equatable.dart'; +import 'package:hosts/model/simple_host_file.dart'; + +/// 服务器设置页面事件 +abstract class ServerSettingsEvent extends Equatable { + const ServerSettingsEvent(); + + @override + List get props => []; +} + +/// 加载服务器设置 +class LoadServerSettings extends ServerSettingsEvent {} + +/// 加载网络接口 +class LoadNetworkInterfaces extends ServerSettingsEvent {} + +/// 切换服务器状态 +class ToggleServerStatus extends ServerSettingsEvent {} + +/// 启动服务器 +class StartServer extends ServerSettingsEvent { + final List? selectedHosts; + + const StartServer(this.selectedHosts); + + @override + List get props => [selectedHosts]; +} + +/// 停止服务器 +class StopServer extends ServerSettingsEvent {} + +/// 刷新服务器状态 +class RefreshServerStatus extends ServerSettingsEvent {} + +/// 更新自动启动设置 +class UpdateAutoStartSettings extends ServerSettingsEvent { + final bool enabled; + final List? selectedHosts; + + const UpdateAutoStartSettings(this.enabled, this.selectedHosts); + + @override + List get props => [enabled, selectedHosts]; +} \ No newline at end of file diff --git a/lib/server/bloc/server_settings_state.dart b/lib/server/bloc/server_settings_state.dart new file mode 100644 index 0000000..7f7e9b0 --- /dev/null +++ b/lib/server/bloc/server_settings_state.dart @@ -0,0 +1,48 @@ +import 'package:equatable/equatable.dart'; + +/// 服务器设置页面状态 +class ServerSettingsState extends Equatable { + const ServerSettingsState({ + this.isLoading = true, + this.isServerEnabled = false, + this.serverStatus, + this.networkInterfaces = const [], + this.errorMessage, + this.isAutoStartEnabled = false, + }); + + final bool isLoading; + final bool isServerEnabled; + final Map? serverStatus; + final List> networkInterfaces; + final String? errorMessage; + final bool isAutoStartEnabled; + + ServerSettingsState copyWith({ + bool? isLoading, + bool? isServerEnabled, + Map? serverStatus, + List>? networkInterfaces, + String? errorMessage, + bool? isAutoStartEnabled, + }) { + return ServerSettingsState( + isLoading: isLoading ?? this.isLoading, + isServerEnabled: isServerEnabled ?? this.isServerEnabled, + serverStatus: serverStatus ?? this.serverStatus, + networkInterfaces: networkInterfaces ?? this.networkInterfaces, + errorMessage: errorMessage, + isAutoStartEnabled: isAutoStartEnabled ?? this.isAutoStartEnabled, + ); + } + + @override + List get props => [ + isLoading, + isServerEnabled, + serverStatus, + networkInterfaces, + errorMessage, + isAutoStartEnabled, + ]; +} \ No newline at end of file diff --git a/lib/server/hosts_server.dart b/lib/server/hosts_server.dart new file mode 100755 index 0000000..15c5b76 --- /dev/null +++ b/lib/server/hosts_server.dart @@ -0,0 +1,375 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/util/file_manager.dart'; +import 'package:hosts/util/settings_manager.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_router/shelf_router.dart'; + +/// HTTP服务器管理类 +/// 提供RESTful API来管理hosts文件 +class HostsServer { + static const int _defaultPort = 1204; + static const String _defaultHost = '0.0.0.0'; + + HttpServer? _server; + final FileManager _fileManager = FileManager(); + + final SettingsManager _settingsManager = SettingsManager(); + + // 本地化字符串映射 + Map _i18nStrings = {}; + + // 允许访问的hosts文件列表 + List _allowedHostFiles = []; + + int _port = _defaultPort; + String _host = _defaultHost; + + /// 获取当前服务器端口 + int get port => _port; + + /// 获取当前服务器主机 + String get host => _host; + + /// 检查服务器是否正在运行 + bool get isRunning => _server != null; + + /// 获取服务器URL + String get serverUrl => 'http://$_host:$_port'; + + /// 设置本地化字符串 + void setI18nStrings(Map strings) { + _i18nStrings = strings; + } + + /// 启动HTTP服务器 + /// [port] 端口号,默认1204 + /// [host] 主机地址,默认0.0.0.0 + /// [i18nStrings] 本地化字符串映射 + /// [allowedHostFiles] 允许访问的hosts文件列表 + Future start( + {int port = _defaultPort, + String host = _defaultHost, + Map? i18nStrings, + List? allowedHostFiles}) async { + if (_server != null) { + throw Exception(_i18nStrings['server_already_running'] ?? + 'Server is already running'); + } + + if (i18nStrings != null) { + _i18nStrings = i18nStrings; + } + + if (allowedHostFiles != null) { + _allowedHostFiles = allowedHostFiles; + } + + _port = port; + _host = host; + + final router = _setupRouter(); + + // 添加CORS中间件 + final handler = Pipeline() + .addMiddleware(corsMiddleware()) + .addMiddleware(logRequests()) + .addHandler(router); + + try { + _server = await shelf_io.serve(handler, _host, _port); + print( + '${_i18nStrings['http_server_start_success'] ?? 'HTTP server started successfully'}: ${serverUrl}'); + } catch (e) { + print( + '${_i18nStrings['http_server_start_failed'] ?? 'Failed to start HTTP server'}: $e'); + rethrow; + } + } + + /// 停止HTTP服务器 + Future stop() async { + if (_server != null) { + await _server!.close(); + _server = null; + print(_i18nStrings['http_server_stopped'] ?? 'HTTP server stopped'); + } + } + + /// 重启HTTP服务器 + Future restart() async { + final currentPort = _port; + final currentHost = _host; + await stop(); + await start(port: currentPort, host: currentHost); + } + + /// 设置路由 + Router _setupRouter() { + final router = Router(); + + // 根路径 - 服务器状态 + router.get('/', _handleStatus); + + // 获取所有hosts文件列表 + router.get('/api/hosts', _handleGetHosts); + + // 获取特定hosts文件内容 + router.get('/api/hosts/', _handleGetHostFile); + + // 获取hosts文件的历史记录 + router.get('/api/hosts//history', _handleGetHostHistory); + + // 获取特定历史记录内容 + router.get( + '/api/hosts//history/', _handleGetHistoryContent); + + return router; + } + + /// CORS中间件 + Middleware corsMiddleware() { + return (Handler innerHandler) { + return (Request request) async { + if (request.method == 'OPTIONS') { + return Response.ok('', headers: _corsHeaders); + } + + final response = await innerHandler(request); + return response.change(headers: _corsHeaders); + }; + }; + } + + /// CORS头部 + Map get _corsHeaders => { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Content-Type': 'application/json; charset=utf-8', + }; + + /// 处理服务器状态请求 + Future _handleStatus(Request request) async { + // 获取应用版本信息 + final packageInfo = await PackageInfo.fromPlatform(); + + final status = { + 'status': 'running', + 'version': packageInfo.version, + 'timestamp': DateTime.now().toIso8601String(), + 'endpoints': [ + 'GET /api/hosts - ${_i18nStrings['get_all_hosts_files'] ?? 'Get all hosts files (JSON)'}', + 'GET /api/hosts/{fileName} - ${_i18nStrings['get_specific_hosts_file'] ?? 'Get specific hosts file content (plain text)'}', + 'GET /api/hosts/{fileName}/history - ${_i18nStrings['get_hosts_file_history'] ?? 'Get hosts file history (JSON)'}', + 'GET /api/hosts/{fileName}/history/{historyId} - ${_i18nStrings['get_specific_history_content'] ?? 'Get specific history content (plain text)'}', + ] + }; + + return Response.ok( + jsonEncode(status), + headers: _corsHeaders, + ); + } + + /// 处理获取所有hosts文件请求 + Future _handleGetHosts(Request request) async { + try { + List hostConfigs = + await _settingsManager.getList(settingKeyHostConfigs); + // 获取所有hosts文件列表 + final List hostFiles = []; + + for (Map config in hostConfigs) { + SimpleHostFile hostFile = SimpleHostFile.fromJson(config); + hostFiles.add(hostFile); + } + + // 根据允许的hosts文件列表进行过滤 + final allowedHosts = hostFiles.where((hostFile) { + // 如果没有设置允许列表,则返回所有文件 + if (_allowedHostFiles.isEmpty) { + return true; + } + // 只返回允许访问的文件 + return _allowedHostFiles.contains(hostFile.fileName); + }).toList(); + + // 转换为API响应格式 + final hosts = allowedHosts + .map((hostFile) => { + 'fileName': hostFile.fileName, + 'remark': hostFile.remark, + }) + .toList(); + + return Response.ok( + jsonEncode({'success': true, 'data': hosts}), + headers: _corsHeaders, + ); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'success': false, 'error': e.toString()}), + headers: _corsHeaders, + ); + } + } + + /// 处理获取特定hosts文件请求 + Future _handleGetHostFile(Request request) async { + final fileName = request.params['fileName']; + if (fileName == null) { + return Response.badRequest( + body: _i18nStrings['missing_file_id'] ?? 'Missing file ID', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + }, + ); + } + + // 验证文件是否在允许访问的列表中 + if (_allowedHostFiles.isNotEmpty && !_allowedHostFiles.contains(fileName)) { + return Response.forbidden( + _i18nStrings['access_denied_file_not_allowed'] ?? 'Access denied: File not allowed', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + }, + ); + } + + try { + final content = await _fileManager.readAsString(fileName); + return Response.ok( + content, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + }, + ); + } catch (e) { + return Response.internalServerError( + body: + '${_i18nStrings['read_file_failed'] ?? 'Failed to read file'}: $e', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + }, + ); + } + } + + /// 处理获取hosts文件历史记录请求 + Future _handleGetHostHistory(Request request) async { + final fileName = request.params['fileName']; + if (fileName == null) { + return Response.badRequest( + body: jsonEncode({ + 'success': false, + 'error': _i18nStrings['missing_file_id'] ?? 'Missing file ID' + }), + headers: _corsHeaders, + ); + } + + // 验证文件是否在允许访问的列表中 + if (_allowedHostFiles.isNotEmpty && !_allowedHostFiles.contains(fileName)) { + return Response.forbidden( + jsonEncode({ + 'success': false, + 'error': _i18nStrings['access_denied_file_not_allowed'] ?? 'Access denied: File not allowed' + }), + headers: _corsHeaders, + ); + } + + try { + final historyList = await _fileManager.getHistory(fileName); + + // 转换为API响应格式 + final historyData = historyList + .map((history) => { + 'id': history.fileName, + 'createTime': DateTime.fromMillisecondsSinceEpoch( + int.tryParse(history.fileName) ?? 0) + .toIso8601String(), + }) + .toList(); + + // 按时间倒序排列 + historyData.sort((a, b) => + b['timestamp'].toString().compareTo(a['timestamp'].toString())); + + return Response.ok( + jsonEncode({'success': true, 'data': historyData}), + headers: _corsHeaders, + ); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'success': false, 'error': e.toString()}), + headers: _corsHeaders, + ); + } + } + + /// 处理获取特定历史记录内容请求 + Future _handleGetHistoryContent(Request request) async { + final fileName = request.params['fileName']; + final historyId = request.params['historyId']; + + if (fileName == null || historyId == null) { + return Response.badRequest( + body: _i18nStrings['missing_file_id_or_history_id'] ?? + 'Missing file ID or history ID', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + }, + ); + } + + // 验证文件是否在允许访问的列表中 + if (_allowedHostFiles.isNotEmpty && !_allowedHostFiles.contains(fileName)) { + return Response.forbidden( + _i18nStrings['access_denied_file_not_allowed'] ?? 'Access denied: File not allowed', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + }, + ); + } + + try { + final historyList = await _fileManager.getHistory(fileName); + final historyItem = historyList.firstWhere( + (h) => h.fileName == historyId, + orElse: () => throw Exception( + _i18nStrings['history_not_found'] ?? 'History not found'), + ); + + final content = _fileManager.readHistoryFile(historyItem.path); + + return Response.ok( + content, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + }, + ); + } catch (e) { + return Response.internalServerError( + body: + '${_i18nStrings['read_history_failed'] ?? 'Failed to read history'}: $e', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + }, + ); + } + } +} diff --git a/lib/server/server_manager.dart b/lib/server/server_manager.dart new file mode 100755 index 0000000..eeefa0d --- /dev/null +++ b/lib/server/server_manager.dart @@ -0,0 +1,189 @@ +import 'dart:async'; +import 'package:hosts/server/hosts_server.dart'; +import 'package:hosts/util/settings_manager.dart'; + +/// 服务器管理器 +/// 负责管理HTTP服务器的生命周期和配置 +class ServerManager { + static final ServerManager _instance = ServerManager._internal(); + factory ServerManager() => _instance; + ServerManager._internal(); + + final HostsServer _server = HostsServer(); + final SettingsManager _settingsManager = SettingsManager(); + + // 配置键 + static const String _serverEnabledKey = 'server_enabled'; + static const String _serverPortKey = 'server_port'; + static const String _serverHostKey = 'server_host'; + static const String _serverAutoStartKey = 'server_auto_start'; + + // 默认配置 + static const int _defaultPort = 1204; + static const String _defaultHost = '0.0.0.0'; + + /// 获取服务器实例 + HostsServer get server => _server; + + /// 检查服务器是否启用 + Future isServerEnabled() async { + return await _settingsManager.getBool(_serverEnabledKey); + } + + /// 设置服务器启用状态 + Future setServerEnabled(bool enabled) async { + await _settingsManager.setBool(_serverEnabledKey, enabled); + } + + /// 获取服务器端口 + Future getServerPort() async { + return await _settingsManager.getInt(_serverPortKey) ?? _defaultPort; + } + + /// 设置服务器端口 + Future setServerPort(int port) async { + await _settingsManager.setInt(_serverPortKey, port); + } + + /// 获取服务器主机 + Future getServerHost() async { + return await _settingsManager.getString(_serverHostKey) ?? _defaultHost; + } + + /// 设置服务器主机 + Future setServerHost(String host) async { + await _settingsManager.setString(_serverHostKey, host); + } + + /// 获取自动启动设置 + Future isAutoStartEnabled() async { + return await _settingsManager.getBool(_serverAutoStartKey); + } + + /// 设置自动启动 + Future setAutoStart(bool enabled) async { + await _settingsManager.setBool(_serverAutoStartKey, enabled); + } + + /// 初始化服务器管理器 + Future initialize() async { + // 如果启用了自动启动,则启动服务器 + if (await isAutoStartEnabled() && await isServerEnabled()) { + await startServer(); + } + } + + /// 启动服务器 + Future startServer({List? allowedHostFiles}) async { + try { + if (_server.isRunning) { + return true; + } + + final port = await getServerPort(); + final host = await getServerHost(); + + await _server.start( + port: port, + host: host, + allowedHostFiles: allowedHostFiles, + ); + await setServerEnabled(true); + + return true; + } catch (e) { + print('启动服务器失败: $e'); + return false; + } + } + + /// 停止服务器 + Future stopServer() async { + try { + await _server.stop(); + await setServerEnabled(false); + } catch (e) { + print('停止服务器失败: $e'); + } + } + + /// 重启服务器 + Future restartServer() async { + try { + await stopServer(); + return await startServer(); + } catch (e) { + print('重启服务器失败: $e'); + return false; + } + } + + /// 获取服务器状态信息 + Future> getServerStatus() async { + return { + 'isRunning': _server.isRunning, + 'isEnabled': await isServerEnabled(), + 'port': await getServerPort(), + 'host': await getServerHost(), + 'autoStart': await isAutoStartEnabled(), + 'url': _server.serverUrl, + }; + } + + /// 更新服务器配置 + Future updateServerConfig({ + int? port, + String? host, + bool? autoStart, + }) async { + try { + bool needRestart = false; + + if (port != null) { + final currentPort = await getServerPort(); + if (currentPort != port) { + await setServerPort(port); + needRestart = true; + } + } + + if (host != null) { + final currentHost = await getServerHost(); + if (currentHost != host) { + await setServerHost(host); + needRestart = true; + } + } + + if (autoStart != null) { + await setAutoStart(autoStart); + } + + // 如果服务器正在运行且配置有变化,需要重启 + if (_server.isRunning && needRestart) { + return await restartServer(); + } + + return true; + } catch (e) { + print('更新服务器配置失败: $e'); + return false; + } + } + + /// 验证端口是否可用 + Future isPortAvailable(int port) async { + try { + // 这里可以添加端口检查逻辑 + // 暂时返回true + return true; + } catch (e) { + return false; + } + } + + /// 清理资源 + Future dispose() async { + await stopServer(); + } +} \ No newline at end of file diff --git a/lib/server/view/nearby_devices_card.dart b/lib/server/view/nearby_devices_card.dart new file mode 100644 index 0000000..86a7ffd --- /dev/null +++ b/lib/server/view/nearby_devices_card.dart @@ -0,0 +1,286 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/server/bloc/nearby_devices_cubit.dart'; +import 'package:hosts/util/nearby_devices_scanner.dart'; +import 'package:hosts/widget/dialog/access_device_dialog.dart'; +import 'package:hosts/widget/dialog/api_documentation_dialog.dart'; + +/// 附近设备卡片组件 +class NearbyDevicesCard extends StatelessWidget { + const NearbyDevicesCard({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + // 处理错误消息 + if (state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage!), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + return Card( + elevation: 1, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppLocalizations.of(context)!.nearby_devices, + style: Theme.of(context).textTheme.titleMedium, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: state.isScanning + ? const SizedBox( + width: 20, + height: 20, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + onPressed: state.isScanning + ? null + : () => context + .read() + .scanNearbyDevices(), + tooltip: + AppLocalizations.of(context)!.scan_nearby_devices, + ), + ], + ), + ], + ), + const SizedBox(height: 16), + if (state.devices.isEmpty && + !state.isScanning && + !state.isLoading) + Text( + AppLocalizations.of(context)!.no_nearby_devices, + style: const TextStyle(color: Colors.grey), + ) + else if (state.devices.isNotEmpty) + _buildDeviceGrid(context, state.devices) + else if (state.isScanning || state.isLoading) + Text( + state.isScanning + ? AppLocalizations.of(context)!.scanning_devices + : AppLocalizations.of(context)!.loading, + style: const TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + }, + ); + } + + /// 构建设备网格 + Widget _buildDeviceGrid(BuildContext context, List devices) { + // 对设备进行排序:共享服务开启的设备在前面 + final sortedDevices = List.from(devices) + ..sort((a, b) { + // 首先按是否开启共享服务排序(开启的在前) + if (a.hasSharing && !b.hasSharing) return -1; + if (!a.hasSharing && b.hasSharing) return 1; + + // 然后按连接状态排序(可连接的在前) + if (a.isReachable && !b.isReachable) return -1; + if (!a.isReachable && b.isReachable) return 1; + + // 最后按IP地址排序 + return a.ip.compareTo(b.ip); + }); + + return LayoutBuilder( + builder: (context, constraints) { + // 根据屏幕宽度确定网格列数 + int crossAxisCount; + if (constraints.maxWidth > 1200) { + crossAxisCount = 4; + } else if (constraints.maxWidth > 800) { + crossAxisCount = 3; + } else if (constraints.maxWidth > 600) { + crossAxisCount = 2; + } else { + crossAxisCount = 1; + } + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: StaggeredGrid.count( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + children: sortedDevices.map((device) { + return StaggeredGridTile.fit( + crossAxisCellCount: 1, + child: _buildDeviceItem(context, device), + ); + }).toList(), + ), + ); + }, + ); + } + + /// 构建设备项 + Widget _buildDeviceItem(BuildContext context, NearbyDevice device) { + // 确定设备状态和颜色 + Color statusColor; + IconData statusIcon; + String statusText; + + if (!device.isReachable) { + statusColor = Colors.red; + statusIcon = Icons.offline_bolt; + statusText = AppLocalizations.of(context)!.offline; + } else if (device.hasSharing) { + statusColor = Colors.green; + statusIcon = Icons.share; + statusText = AppLocalizations.of(context)!.sharing_enabled; + } else { + statusColor = Colors.orange; + statusIcon = Icons.computer; + statusText = AppLocalizations.of(context)!.device_reachable; + } + + return Card( + elevation: 0, + margin: EdgeInsets.zero, + color: Theme.of(context).colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const SizedBox(width: 8,), + Stack( + children: [ + Icon( + statusIcon, + color: statusColor, + size: 24, + ), + // 连接状态指示器 + if (device.isReachable) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + device.ip, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: device.isReachable ? null : Colors.grey, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (!device.isReachable) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3)), + ), + child: Text( + AppLocalizations.of(context)!.offline, + style: TextStyle( + fontSize: 10, + color: Colors.red[700], + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + statusText, + style: TextStyle( + fontSize: 14, + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + if (device.hasSharing && device.isReachable) + IconButton( + icon: const Icon(Icons.remove_red_eye), + onPressed: () { + showApiDocumentationDialog(context, device.ip); + }, + tooltip: AppLocalizations.of(context)!.api_docs, + ), + if (device.hasSharing && device.isReachable) + IconButton( + icon: const Icon(Icons.launch), + onPressed: () { + accessDeviceDialog( + context, + device, + ); + }, + tooltip: AppLocalizations.of(context)!.visit_device, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/server/view/server_settings_page.dart b/lib/server/view/server_settings_page.dart new file mode 100755 index 0000000..c86d62f --- /dev/null +++ b/lib/server/view/server_settings_page.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/server/bloc/nearby_devices_cubit.dart'; +import 'package:hosts/server/bloc/server_settings_bloc.dart'; +import 'package:hosts/server/bloc/server_settings_event.dart'; +import 'package:hosts/server/bloc/server_settings_state.dart'; +import 'package:hosts/server/view/nearby_devices_card.dart'; +import 'package:hosts/server/view/server_status_card.dart'; + +/// 服务器设置页面 +class ServerSettingsPage extends StatelessWidget { + const ServerSettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ServerSettingsBloc() + ..add(LoadServerSettings()) + ..add(LoadNetworkInterfaces()), + ), + BlocProvider( + create: (context) => NearbyDevicesCubit(), + ), + ], + child: const _ServerSettingsView(), + ); + } +} + +class _ServerSettingsView extends StatelessWidget { + const _ServerSettingsView(); + + // 使用 GlobalKey 保持组件状态 + static final GlobalKey _nearbyDevicesKey = GlobalKey(); + static final GlobalKey _serverStatusKey = GlobalKey(); + static final GlobalKey _apiDocsKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.remote_sync), + ), + body: BlocConsumer( + listener: (context, state) { + if (state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage!), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + if (state.isLoading && state.serverStatus == null) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: _buildResponsiveLayout(context, state), + ); + }, + ), + ); + } + + /// 构建响应式布局 + Widget _buildResponsiveLayout( + BuildContext context, ServerSettingsState state) { + return LayoutBuilder( + builder: (context, constraints) { + // 判断是否为大屏幕 (宽度大于 800px 认为是大屏) + final bool isLargeScreen = constraints.maxWidth > 800; + + if (isLargeScreen) { + // 大屏布局:上排两列,下排全宽 + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 上排:服务状态和API文档并排 + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 左侧:服务器状态卡片 + Expanded( + flex: 1, + child: _buildStatusCard(context, state), + ), + const SizedBox(width: 16), + // 右侧:API文档卡片 + Expanded( + flex: 1, + child: _buildApiDocsCard(context), + ), + ], + ), + ), + const SizedBox(height: 16), + // 下排:附近设备卡片(全宽) + NearbyDevicesCard(key: _nearbyDevicesKey), + ], + ); + } else { + // 小屏布局:垂直排列 + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 服务器状态卡片 + _buildStatusCard(context, state), + const SizedBox(height: 16), + // 附近设备卡片 + NearbyDevicesCard(key: _nearbyDevicesKey), + const SizedBox(height: 16), + // API文档卡片 + _buildApiDocsCard(context), + ], + ); + } + }, + ); + } + + /// 构建状态卡片 + Widget _buildStatusCard(BuildContext context, ServerSettingsState state) { + return ServerStatusCard( + key: _serverStatusKey, + serverStatus: state.serverStatus, + networkInterfaces: state.networkInterfaces, + onStartServer: (selectedHosts) { + context.read().add(StartServer(selectedHosts)); + }, + onStopServer: () { + context.read().add(StopServer()); + }, + isAutoStartEnabled: state.isAutoStartEnabled, + onAutoStartChanged: (enabled, selectedHosts) { + context.read().add( + UpdateAutoStartSettings(enabled, selectedHosts), + ); + }, + ); + } + + /// 构建API文档卡片 + Widget _buildApiDocsCard(BuildContext context) { + return Card( + key: _apiDocsKey, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.api_docs, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Text( + '${AppLocalizations.of(context)!.api_endpoints}:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + _buildApiEndpoint(context, 'GET', '/', + AppLocalizations.of(context)!.server_status), + _buildApiEndpoint(context, 'GET', '/api/hosts', + AppLocalizations.of(context)!.get_all_hosts_files), + _buildApiEndpoint(context, 'GET', '/api/hosts/{fileName}', + AppLocalizations.of(context)!.get_specific_hosts_file), + _buildApiEndpoint(context, 'GET', '/api/hosts/{fileName}/history', + AppLocalizations.of(context)!.get_hosts_file_history), + _buildApiEndpoint( + context, + 'GET', + '/api/hosts/{fileName}/history/{historyId}', + AppLocalizations.of(context)!.get_specific_history_content), + ], + ), + ), + ); + } + + /// 构建API端点项 + Widget _buildApiEndpoint( + BuildContext context, String method, String path, String description) { + Color methodColor; + switch (method) { + case 'GET': + methodColor = Theme.of(context).colorScheme.primary; + break; + case 'POST': + methodColor = Theme.of(context).colorScheme.secondary; + break; + case 'PUT': + methodColor = Theme.of(context).colorScheme.tertiary; + break; + case 'DELETE': + methodColor = Theme.of(context).colorScheme.error; + break; + default: + methodColor = Theme.of(context).colorScheme.outline; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: methodColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + method, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + path, + style: const TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + ), + ), + Text( + description, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/server/view/server_status_card.dart b/lib/server/view/server_status_card.dart new file mode 100644 index 0000000..ffc55cf --- /dev/null +++ b/lib/server/view/server_status_card.dart @@ -0,0 +1,459 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/widget/dialog/qr_code_dialog.dart'; +import 'package:hosts/widget/dialog/select_hosts_dialog.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 服务器状态卡片组件 +class ServerStatusCard extends StatelessWidget { + final Map? serverStatus; + final List> networkInterfaces; + final Function(List?) onStartServer; + final VoidCallback onStopServer; + final bool isAutoStartEnabled; + final Function(bool, List?) onAutoStartChanged; + + const ServerStatusCard({ + super.key, + required this.serverStatus, + required this.networkInterfaces, + required this.onStartServer, + required this.onStopServer, + required this.isAutoStartEnabled, + required this.onAutoStartChanged, + }); + + /// 复制URL到剪贴板 + void _copyUrl(BuildContext context, String url) { + Clipboard.setData(ClipboardData(text: url)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.server_url_copied), + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ); + } + + /// 显示二维码对话框 + void _showQrCodeDialog(BuildContext context, String url) { + QrCodeDialog.show(context, url: url); + } + + /// 打开URL + Future _launchUrl(BuildContext context, String url) async { + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.unable_to_open(url))), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${AppLocalizations.of(context)!.unable_to_open(url)}: $e')), + ); + } + } + } + + /// 处理服务器切换 + Future _handleServerToggle(BuildContext context) async { + final isRunning = serverStatus?['isRunning'] ?? false; + + if (isRunning) { + // 如果服务器正在运行,直接停止 + onStopServer(); + } else { + // 如果服务器未运行,显示选择hosts文件对话框 + final selectedHosts = await SelectHostsDialog.show(context); + if (selectedHosts != null && selectedHosts.isNotEmpty) { + onStartServer(selectedHosts); + } + } + } + + /// 处理自动启动开关切换 + Future _handleAutoStartToggle(BuildContext context, bool value) async { + if (value) { + // 启用自动启动,需要选择hosts文件 + final selectedHosts = await SelectHostsDialog.show(context); + if (selectedHosts != null && selectedHosts.isNotEmpty) { + onAutoStartChanged(true, selectedHosts); + } + } else { + // 禁用自动启动 + onAutoStartChanged(false, null); + } + } + + /// 构建地址芯片 + Widget _buildAddressChip( + BuildContext context, { + required String label, + required String url, + }) { + // 不省略任何IP信息,全部展示 + final displayLabel = label; + + // 根据IP地址类型确定颜色主题 + final isLocalhost = label.contains('127.0.0.1'); + final isPrivateNetwork = label.contains('192.168.') || + label.contains('10.') || + label.contains('172.'); + + Color primaryColor; + Color backgroundColor; + Color borderColor; + + if (isLocalhost) { + // localhost 使用灰色主题 + primaryColor = Theme.of(context).colorScheme.onSurfaceVariant; + backgroundColor = Theme.of(context).colorScheme.surfaceContainerHighest; + borderColor = Theme.of(context).colorScheme.outline; + } else if (isPrivateNetwork) { + // 私有网络使用主色调 + primaryColor = Theme.of(context).colorScheme.primary; + backgroundColor = Theme.of(context).colorScheme.primaryContainer; + borderColor = Theme.of(context).colorScheme.primary; + } else { + // 其他地址使用次要色调 + primaryColor = Theme.of(context).colorScheme.secondary; + backgroundColor = Theme.of(context).colorScheme.secondaryContainer; + borderColor = Theme.of(context).colorScheme.secondary; + } + + return Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: borderColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 二维码按钮 + IconButton( + icon: Icon( + Icons.qr_code, + size: 18, + color: primaryColor, + ), + onPressed: () => _showQrCodeDialog(context, url), + tooltip: AppLocalizations.of(context)!.show_qr_code, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + ), + const SizedBox(width: 8), + // IP地址文本(可点击) + Flexible( + child: InkWell( + onTap: () => _launchUrl(context, url), + child: Text( + displayLabel, + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + color: primaryColor, + decoration: TextDecoration.underline, + decorationColor: primaryColor.withValues(alpha: 0.5), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 8), + // 复制按钮 + IconButton( + icon: Icon( + Icons.copy, + size: 18, + color: primaryColor, + ), + onPressed: () => _copyUrl(context, url), + tooltip: AppLocalizations.of(context)!.copy_url, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final isRunning = serverStatus?['isRunning'] ?? false; + final url = serverStatus?['url'] ?? ''; + final port = serverStatus?['port'] ?? 1204; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + Text( + AppLocalizations.of(context)!.server_status, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + // 服务器状态行 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isRunning + ? Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: 0.3) + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isRunning + ? Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.2) + : Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.1), + width: 1, + ), + ), + child: Row( + children: [ + // 状态文字 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isRunning + ? AppLocalizations.of(context)!.server_running + : AppLocalizations.of(context)!.server_stopped, + style: + Theme.of(context).textTheme.titleSmall?.copyWith( + color: isRunning + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + if (isRunning && port != null) ...[ + const SizedBox(height: 2), + Text( + '${AppLocalizations.of(context)!.port}: $port', + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.7), + ), + ), + ], + ], + ), + ), + // 操作按钮 + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: isRunning + ? Colors.red.withValues(alpha: 0.2) + : Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ElevatedButton.icon( + onPressed: () => _handleServerToggle(context), + style: ElevatedButton.styleFrom( + backgroundColor: isRunning + ? Colors.red.shade600 + : Colors.green.shade600, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 4, + shadowColor: isRunning + ? Colors.red.withValues(alpha: 0.3) + : Colors.green.withValues(alpha: 0.3), + ), + icon: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Icon( + isRunning + ? Icons.stop_rounded + : Icons.play_arrow_rounded, + size: 20, + color: Colors.white, + ), + ), + label: Text( + isRunning + ? AppLocalizations.of(context)!.server_stop + : AppLocalizations.of(context)!.server_start, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + // 自动启动功能开关 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isAutoStartEnabled + ? Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: 0.3) + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isAutoStartEnabled + ? Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.2) + : Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.1), + width: 1, + ), + ), + child: Row( + children: [ + // 状态文字 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.server_auto_start, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: isAutoStartEnabled + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + AppLocalizations.of(context)!.server_auto_start_desc, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.7), + ), + ), + ], + ), + ), + // 开关 + Switch( + value: isAutoStartEnabled, + onChanged: (value) => _handleAutoStartToggle(context, value), + activeColor: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + const SizedBox(height: 12), + // 服务器地址信息 + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${AppLocalizations.of(context)!.server_address}:', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // 显示所有网络接口的IP地址 + ...networkInterfaces.map((interface) { + final address = interface['address']!; + // 检查是否为IPv6地址,如果是则需要用方括号包围 + final formattedAddress = address.contains(':') && !address.startsWith('[') + ? '[$address]' + : address; + final serverUrl = 'http://$formattedAddress:$port'; + + return _buildAddressChip( + context, + label: serverUrl.replaceFirst('http://', ''), + url: serverUrl, + ); + }), + // 如果服务器运行中且有不同的URL,也显示实际运行的URL + if (isRunning && + url.isNotEmpty && + !networkInterfaces.any((interface) => + 'http://${interface['address']}:$port' == url)) + _buildAddressChip( + context, + label: url.replaceFirst('http://', ''), + url: url, + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/util/device_api_cache.dart b/lib/util/device_api_cache.dart new file mode 100644 index 0000000..d16773a --- /dev/null +++ b/lib/util/device_api_cache.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// 设备API直接访问工具类 +class DeviceApiCache { + static const int _defaultPort = 1204; + static const Duration _scanTimeout = Duration(seconds: 2); + + /// 获取设备API响应(直接访问,不缓存) + static Future>> getCachedDeviceData(String ip) async { + try { + final httpClient = HttpClient(); + httpClient.connectionTimeout = _scanTimeout; + httpClient.idleTimeout = _scanTimeout; + + final request = await httpClient.get(ip, _defaultPort, '/api/hosts'); + final response = await request.close(); + + if (response.statusCode == 200) { + final responseBody = await response.transform(utf8.decoder).join(); + + try { + final responseData = jsonDecode(responseBody); + if (responseData['success'] == true) { + final List hostsList = responseData['data']; + + // 只返回fileName和remark字段 + final List> filteredHostsList = hostsList + .map((host) => { + 'fileName': host['fileName'], + 'remark': host['remark'] ?? '', + }) + .toList(); + + httpClient.close(); + return filteredHostsList; + } + } catch (e) { + print('解析hosts文件列表失败 $ip: $e'); + } + } + + httpClient.close(); + return []; + } catch (e) { + print('获取设备数据失败 $ip: $e'); + return []; + } + } + + /// 获取特定hosts文件的内容(直接访问,不缓存) + static Future getCachedHostsFileContent(String ip, String fileName) async { + try { + final httpClient = HttpClient(); + httpClient.connectionTimeout = _scanTimeout; + httpClient.idleTimeout = _scanTimeout; + + final request = await httpClient.get(ip, _defaultPort, '/api/hosts/$fileName'); + final response = await request.close(); + + if (response.statusCode == 200) { + final responseBody = await response.transform(utf8.decoder).join(); + httpClient.close(); + return responseBody; + } + + httpClient.close(); + return null; + } catch (e) { + print('获取hosts文件内容失败 $ip/$fileName: $e'); + return null; + } + } + + /// 获取特定hosts文件的历史记录列表(直接访问,不缓存) + static Future>> getCachedHostsFileHistory(String ip, String fileName) async { + try { + final httpClient = HttpClient(); + httpClient.connectionTimeout = _scanTimeout; + httpClient.idleTimeout = _scanTimeout; + + final request = await httpClient.get(ip, _defaultPort, '/api/hosts/$fileName/history'); + final response = await request.close(); + + if (response.statusCode == 200) { + final responseBody = await response.transform(utf8.decoder).join(); + + try { + final responseData = jsonDecode(responseBody); + if (responseData['success'] == true) { + final List historyList = responseData['data']; + httpClient.close(); + return historyList.map((item) => item as Map).toList(); + } + } catch (e) { + print('解析hosts文件历史失败 $ip/$fileName: $e'); + } + } + + httpClient.close(); + return []; + } catch (e) { + print('获取hosts文件历史失败 $ip/$fileName: $e'); + return []; + } + } + + /// 获取特定hosts文件的历史内容(直接访问,不缓存) + static Future getCachedHostsFileHistoryContent(String ip, String fileName, String historyFileName) async { + try { + final httpClient = HttpClient(); + httpClient.connectionTimeout = _scanTimeout; + httpClient.idleTimeout = _scanTimeout; + + final request = await httpClient.get(ip, _defaultPort, '/api/hosts/$fileName/history/$historyFileName'); + final response = await request.close(); + + if (response.statusCode == 200) { + final responseBody = await response.transform(utf8.decoder).join(); + httpClient.close(); + return responseBody; + } + + httpClient.close(); + return null; + } catch (e) { + print('获取hosts文件历史内容失败 $ip/$fileName/$historyFileName: $e'); + return null; + } + } +} diff --git a/lib/util/file_manager.dart b/lib/util/file_manager.dart old mode 100644 new mode 100755 index a60a05a..354c9e2 --- a/lib/util/file_manager.dart +++ b/lib/util/file_manager.dart @@ -1,10 +1,30 @@ import 'dart:convert'; import 'dart:io'; +import 'package:archive/archive.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:hosts/model/host_file.dart'; import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/util/regexp_util.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +// 导入hosts文件的数据模型 +class ImportableHost { + final String remark; + final String fileName; + final String folderPath; + final bool hasContent; + + ImportableHost({ + required this.remark, + required this.fileName, + required this.folderPath, + required this.hasContent, + }); +} + class FileManager { // 私有构造函数 FileManager._internal() { @@ -18,6 +38,9 @@ class FileManager { // 静态变量保存单例实例 static final FileManager _instance = FileManager._internal(); + static const MethodChannel _channel = + MethodChannel('top.webb_l.hosts/system'); + // 工厂构造函数返回单例实例 factory FileManager() => _instance; @@ -82,6 +105,16 @@ class FileManager { } } + Future readAsString(String fileId) async { + final path = await getHostsFilePath(fileId); + return File(path).readAsString(); + } + + Future> readAsLines(String fileId) async { + final path = await getHostsFilePath(fileId); + return await File(path).readAsLines(); + } + // 删除文件 Future deleteFiles(List fileNames) async { if (_cachedDirectory == null) await _initializeDirectory(); @@ -126,7 +159,7 @@ class FileManager { if (!historyDirectory.existsSync()) { return []; } - return historyDirectory + final historyList = historyDirectory .listSync() .map( (item) => SimpleHostFileHistory( @@ -135,9 +168,14 @@ class FileManager { ), ) .toList(); + + // 按fileName倒序排序 + historyList.sort((a, b) => b.fileName.compareTo(a.fileName)); + + return historyList; } - void saveHistory(String fileId, String content) async { + Future saveHistory(String fileId, String content) async { if (_cachedDirectory == null) await _initializeDirectory(); if (fileId.isEmpty) return; @@ -145,18 +183,20 @@ class FileManager { final safeFileName = p.basename(fileId); // 只保留文件名,不允许路径 final filePath = p.join(_cachedDirectory!.path, safeFileName); Directory rootDirectory = Directory(filePath); - if (!rootDirectory.existsSync()) { + if (!(await rootDirectory.exists())) { rootDirectory.create(recursive: true); } Directory historyDirectory = Directory(p.join(rootDirectory.path, "history")); - if (!historyDirectory.existsSync()) { + if (!(await historyDirectory.exists())) { historyDirectory.create(recursive: true); } - File( + final File file = File( p.join(historyDirectory.path, DateTime.now().millisecondsSinceEpoch.toString()), - ).writeAsString(content); + ); + + await file.writeAsString(content); } void deleteFile(String path) { @@ -198,7 +238,7 @@ class FileManager { String cacheFilePath, String systemHostFilePath) async { String result = ""; - // TODO Windows Mac + // TODO Windows if (Platform.isLinux) { final Process process = await Process.start( "pkexec", @@ -219,7 +259,7 @@ class FileManager { }); // 等待进程结束 - int exitCode = await process.exitCode; + await process.exitCode; // 检查退出代码,如果非零则抛出异常 if (errorMessage.isNotEmpty) { @@ -227,6 +267,385 @@ class FileManager { } } + if (Platform.isMacOS) { + try { + final result = await _channel.invokeMethod('modifyHostsFile', + {'content': File(cacheFilePath).readAsStringSync()}); + + if (result == null) { + throw Exception("修改hosts文件失败"); + } + } on PlatformException catch (e) { + print('修改hosts文件失败: ${e.message}'); + throw e; // 重新抛出异常,让调用者处理 + } + } + return result; } + + Future exportHostFile(SimpleHostFile hostFile, String dialogTitle) async { + try { + if (_cachedDirectory == null) await _initializeDirectory(); + + // 获取要导出的目录路径 + final String directoryPath = p.join(_cachedDirectory!.path, hostFile.fileName); + final Directory exportDirectory = Directory(directoryPath); + + if (!await exportDirectory.exists()) { + print('Directory does not exist: $directoryPath'); + return false; + } + + // 让用户选择保存路径 + String? outputFilePath = await FilePicker.platform.saveFile( + dialogTitle: dialogTitle, + fileName: '${hostFile.remark}_${hostFile.fileName}.zip', + type: FileType.custom, + allowedExtensions: ['zip'], + ); + + if (outputFilePath != null) { + // 创建压缩包 + final Archive archive = Archive(); + + // 递归添加目录中的所有文件到压缩包 + await _addDirectoryToArchive(exportDirectory, archive, hostFile.fileName); + + // 编码压缩包 + final List zipData = ZipEncoder().encode(archive)!; + + // 写入文件 + await File(outputFilePath).writeAsBytes(zipData); + return true; + } + return false; + } catch (e) { + print('Export failed: $e'); + return false; + } + } + + // 递归添加目录到压缩包的辅助方法 + Future _addDirectoryToArchive( + Directory directory, Archive archive, String baseName) async { + final List entities = directory.listSync(); + + for (FileSystemEntity entity in entities) { + if (entity is File) { + final String relativePath = p.relative(entity.path, + from: p.join(_cachedDirectory!.path, baseName)); + final List fileBytes = await entity.readAsBytes(); + final ArchiveFile file = + ArchiveFile(relativePath, fileBytes.length, fileBytes); + archive.addFile(file); + } else if (entity is Directory) { + await _addDirectoryToArchive(entity, archive, baseName); + } + } + } + + // 递归添加目录到压缩包的辅助方法(自定义文件夹名称) + Future _addDirectoryToArchiveWithCustomName( + Directory directory, Archive archive, String baseName, String customFolderName) async { + final List entities = directory.listSync(); + + for (FileSystemEntity entity in entities) { + if (entity is File) { + final String relativePath = p.relative(entity.path, + from: p.join(_cachedDirectory!.path, baseName)); + final String customPath = p.join(customFolderName, relativePath); + final List fileBytes = await entity.readAsBytes(); + final ArchiveFile file = + ArchiveFile(customPath, fileBytes.length, fileBytes); + archive.addFile(file); + } else if (entity is Directory) { + await _addDirectoryToArchiveWithCustomName(entity, archive, baseName, customFolderName); + } + } + } + + Future exportMultipleHostFiles(List hostFiles, String dialogTitle) async { + try { + if (_cachedDirectory == null) await _initializeDirectory(); + + if (hostFiles.isEmpty) { + return false; + } + + // 让用户选择保存路径 + String defaultFileName; + if (hostFiles.length == 1) { + // 单个文件时使用该文件的备注作为文件名 + defaultFileName = '${hostFiles.first.remark}_${hostFiles.first.fileName}.zip'; + } else { + // 多个文件时使用通用名称 + defaultFileName = 'hosts_batch_export.zip'; + } + + String? outputFilePath = await FilePicker.platform.saveFile( + dialogTitle: dialogTitle, + fileName: defaultFileName, + type: FileType.custom, + allowedExtensions: ['zip'], + ); + + if (outputFilePath != null) { + // 创建压缩包 + final Archive archive = Archive(); + + // 为每个host文件添加到压缩包 + for (SimpleHostFile hostFile in hostFiles) { + final String directoryPath = p.join(_cachedDirectory!.path, hostFile.fileName); + final Directory exportDirectory = Directory(directoryPath); + + if (await exportDirectory.exists()) { + // 创建以 {remark}_{fileName} 命名的文件夹 + final String folderName = '${hostFile.remark}_${hostFile.fileName}'; + await _addDirectoryToArchiveWithCustomName(exportDirectory, archive, hostFile.fileName, folderName); + } + } + + // 编码压缩包 + final List zipData = ZipEncoder().encode(archive)!; + + // 写入文件 + await File(outputFilePath).writeAsBytes(zipData); + return true; + } + return false; + } catch (e) { + print('Batch export failed: $e'); + return false; + } + } + + // 解析导入文件,返回可导入的hosts列表 + Future> parseImportFile(String filePath) async { + List importableHosts = []; + + try { + if (filePath.toLowerCase().endsWith('.zip')) { + // 解析ZIP文件 + final bytes = await File(filePath).readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + // 查找所有可能的hosts文件夹 + Map hostFolders = {}; + + for (final file in archive) { + if (file.isFile && file.name.endsWith('/hosts')) { + // 获取文件夹名称 + final parts = file.name.split('/'); + if (parts.length >= 2) { + final folderName = parts[parts.length - 2]; + hostFolders[folderName] = file.name; + } + } + } + + // 为每个hosts文件夹创建ImportableHost + for (final entry in hostFolders.entries) { + final folderName = entry.key; + final hostFilePath = entry.value; + + // 尝试解析文件夹名称为remark和fileName + String remark = folderName; + String fileName = folderName; + + // 如果文件夹名称包含下划线,尝试分割 + if (folderName.contains('_')) { + final lastUnderscoreIndex = folderName.lastIndexOf('_'); + remark = folderName.substring(0, lastUnderscoreIndex); + fileName = folderName.substring(lastUnderscoreIndex + 1); + } + + importableHosts.add(ImportableHost( + remark: remark, + fileName: fileName, + folderPath: hostFilePath.substring(0, hostFilePath.lastIndexOf('/')), + hasContent: true, + )); + } + } else { + // 单个hosts文件 + final fileName = p.basenameWithoutExtension(filePath); + importableHosts.add(ImportableHost( + remark: fileName, + fileName: fileName, + folderPath: filePath, + hasContent: await File(filePath).exists(), + )); + } + } catch (e) { + print('解析导入文件失败: $e'); + } + + return importableHosts; + } + + // 导入选中的hosts文件 + Future> importSelectedHosts(String filePath, List selectedHosts, List existingFileNames) async { + List importedFiles = []; + + try { + if (_cachedDirectory == null) await _initializeDirectory(); + + if (filePath.toLowerCase().endsWith('.zip')) { + // 从ZIP文件导入 + final bytes = await File(filePath).readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + for (final selectedHost in selectedHosts) { + // 直接使用原文件名,如果存在则覆盖 + String fileName = selectedHost.fileName; + String targetDir = p.join(_cachedDirectory!.path, fileName); + + // 如果目录已存在,先删除 + if (await Directory(targetDir).exists()) { + await Directory(targetDir).delete(recursive: true); + } + + // 创建目标目录 + await Directory(targetDir).create(recursive: true); + await Directory(p.join(targetDir, 'history')).create(recursive: true); + + // 提取文件 + for (final file in archive) { + if (file.isFile && file.name.startsWith(selectedHost.folderPath)) { + final relativePath = file.name.substring(selectedHost.folderPath.length + 1); + final targetPath = p.join(targetDir, relativePath); + + // 确保目标目录存在 + await Directory(p.dirname(targetPath)).create(recursive: true); + + // 写入文件 + await File(targetPath).writeAsBytes(file.content as List); + } + } + + // 创建SimpleHostFile对象 + final importedFile = SimpleHostFile( + fileName: fileName, + remark: selectedHost.remark, + ); + importedFiles.add(importedFile); + existingFileNames.add(fileName); + } + } else { + // 单个文件导入 + if (selectedHosts.isNotEmpty) { + final selectedHost = selectedHosts.first; + String fileName = selectedHost.fileName; + String targetDir = p.join(_cachedDirectory!.path, fileName); + + // 如果目录已存在,先删除 + if (await Directory(targetDir).exists()) { + await Directory(targetDir).delete(recursive: true); + } + + // 创建目标目录 + await Directory(targetDir).create(recursive: true); + await Directory(p.join(targetDir, 'history')).create(recursive: true); + + // 复制文件 + final sourceFile = File(filePath); + final targetFile = File(p.join(targetDir, 'hosts')); + await sourceFile.copy(targetFile.path); + + // 创建SimpleHostFile对象 + final importedFile = SimpleHostFile( + fileName: fileName, + remark: selectedHost.remark, + ); + importedFiles.add(importedFile); + } + } + + return importedFiles; + } catch (e) { + print('导入失败: $e'); + return []; + } + } + + List parseHosts(List lines) { + List tempHosts = []; + + for (int i = 0; i < lines.length; i++) { + final line = lines[i].trim(); + if (line.isNotEmpty && (isValidIPv4(line) || isValidIPv6(line))) { + final parts = line + .replaceFirst("#", "") + .split(RegExp(r"\s+")) + .where((it) => it.trim().isNotEmpty) + .toList(); + + if (parts.length < 2) continue; + + String host = parts.first; + List hosts = parts.sublist(1); + + int? descLine; + String description = ""; + + Map config = {}; + + List lineDescription = line.contains(RegExp(r"\s+#\s?")) + ? line + .split(RegExp(r"\s+#\s?")) + .where((it) => it.trim().isNotEmpty) + .toList() + : []; + + if (lineDescription.isNotEmpty) { + print(lineDescription); + final List tempLineDescription = lineDescription.sublist(1); + final String temp = tempLineDescription.length > 1 + ? tempLineDescription.join("# ") + : "# ${tempLineDescription.join("")}"; + + final RegExp regExp = RegExp(r"# - config \{([^{}]*)\}"); + final String tempDescription = temp.contains(regExp) + ? temp.replaceAll(regExp, "") + : temp.replaceFirst("# ", ""); + if (tempDescription.trim().isNotEmpty) { + description = tempDescription; + } + + final RegExpMatch? match = regExp.firstMatch(temp); + if (match != null) { + try { + config = jsonDecode("{${match.group(1)}}"); + } catch (e) { + print("错误:解析配置失败"); + } + } + + hosts = lineDescription.first + .replaceFirst("#", "") + .split(RegExp(r"\s+")) + .where((it) => it.trim().isNotEmpty) + .toList() + .sublist(1); + } + + if (i > 0 && description.isEmpty) { + final prevLine = lines[i - 1].trim(); + if (prevLine.isNotEmpty && + prevLine.startsWith("#") && + !(isValidIPv4(prevLine) || isValidIPv6(prevLine))) { + description = prevLine.replaceFirst(RegExp(r"^#\s?"), ""); + descLine = i - 1; + } + } + + tempHosts.add(HostsModel(host, !line.startsWith(RegExp(r"^\s?#")), + description, hosts, config, + hostLine: i, descLine: descLine)); + } + } + + return tempHosts; + } } diff --git a/lib/util/nearby_devices_scanner.dart b/lib/util/nearby_devices_scanner.dart new file mode 100644 index 0000000..8ed015b --- /dev/null +++ b/lib/util/nearby_devices_scanner.dart @@ -0,0 +1,256 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:network_info_plus/network_info_plus.dart'; + +/// 附近设备扫描器 +class NearbyDevicesScanner { + static const int _defaultPort = 1204; + static const Duration _scanTimeout = Duration(seconds: 2); + + + /// 实时扫描附近设备,发现设备时立即回调 + static Future scanNearbyDevicesRealTime({ + required Function(NearbyDevice) onDeviceFound, + }) async { + try { + print('开始实时扫描附近设备...'); + + // 获取当前设备的IP地址 + String? currentIP = await _getCurrentIP(); + + if (currentIP == null || currentIP.isEmpty) { + print('无法获取当前设备IP地址'); + return; + } + + print('当前设备IP: $currentIP'); + + // 解析IP地址段 + final ipParts = currentIP.split('.'); + if (ipParts.length != 4) { + print('IP地址格式错误: $currentIP'); + return; + } + + final baseIP = '${ipParts[0]}.${ipParts[1]}.${ipParts[2]}'; + print('扫描网段: $baseIP.1-254'); + + // 创建所有需要扫描的IP列表 + final List ipsToScan = []; + + // 添加所有IP + for (int i = 1; i <= 254; i++) { + final targetIP = '$baseIP.$i'; + if (targetIP != currentIP) { + ipsToScan.add(targetIP); + } + } + + // 分批扫描,每批最多50个设备,避免过多并发连接 + const int batchSize = 50; + for (int start = 0; start < ipsToScan.length; start += batchSize) { + final int end = (start + batchSize).clamp(0, ipsToScan.length); + final batch = ipsToScan.sublist(start, end); + + final List> scanFutures = batch + .map((ip) => _scanDevice(ip)) + .toList(); + + // 使用 forEach 来处理每个完成的扫描结果 + for (final future in scanFutures) { + future.then((device) async { + if (device != null) { + print('发现设备: ${device.ip}'); + onDeviceFound(device); + } + }); + } + + // 等待当前批次扫描完成 + await Future.wait(scanFutures); + } + } catch (e) { + print('实时扫描附近设备失败: $e'); + } + } + + /// 扫描附近设备(兼容性保留) + static Future> scanNearbyDevices() async { + final List devices = []; + await scanNearbyDevicesRealTime( + onDeviceFound: (device) => devices.add(device), + ); + return devices; + } + + /// 获取当前设备IP地址 + static Future _getCurrentIP() async { + try { + // 首先尝试获取WiFi IP + final info = NetworkInfo(); + final wifiIP = await info.getWifiIP(); + + if (wifiIP != null && wifiIP.isNotEmpty && wifiIP != '0.0.0.0') { + return wifiIP; + } + + // 如果WiFi IP获取失败,尝试从网络接口获取 + final interfaces = await NetworkInterface.list( + includeLoopback: false, + includeLinkLocal: false, + type: InternetAddressType.IPv4, + ); + + for (final interface in interfaces) { + for (final address in interface.addresses) { + final ip = address.address; + // 寻找私有网络地址 + if (ip.startsWith('192.168.') || + ip.startsWith('10.') || + (ip.startsWith('172.') && + int.tryParse(ip.split('.')[1]) != null && + int.parse(ip.split('.')[1]) >= 16 && + int.parse(ip.split('.')[1]) <= 31)) { + return ip; + } + } + } + + return null; + } catch (e) { + print('获取当前IP失败: $e'); + return null; + } + } + + /// 扫描单个设备 + static Future _scanDevice(String ip) async { + try { + // 尝试连接设备的共享端口 + final socket = await Socket.connect(ip, _defaultPort, timeout: _scanTimeout); + await socket.close(); + + // 连接成功,进一步验证是否是hosts服务器 + final bool isHostsServer = await _verifyHostsServer(ip); + + return NearbyDevice( + ip: ip, + isReachable: true, + hasSharing: isHostsServer, + lastSeen: DateTime.now(), + ); + } catch (e) { + // 连接失败,设备不可达或未开启共享 + return null; + } + } + + /// 验证是否是hosts服务器 + static Future _verifyHostsServer(String ip) async { + try { + final httpClient = HttpClient(); + httpClient.connectionTimeout = _scanTimeout; + httpClient.idleTimeout = _scanTimeout; + + final request = await httpClient.get(ip, _defaultPort, '/'); + final response = await request.close(); + + if (response.statusCode == 200) { + final responseBody = await response.transform(utf8.decoder).join(); + // 检查响应是否包含hosts服务器的特征 + if (responseBody.contains('status') && responseBody.contains('running')) { + httpClient.close(); + return true; + } + } + + httpClient.close(); + return false; + } catch (e) { + return false; + } + } + + /// 检查特定设备是否开启共享 + static Future checkDeviceSharing(String ip) async { + try { + final socket = await Socket.connect(ip, _defaultPort, timeout: _scanTimeout); + await socket.close(); + return await _verifyHostsServer(ip); + } catch (e) { + return false; + } + } + + /// 测试本地服务器是否正在运行 + static Future testLocalServer() async { + try { + final currentIP = await _getCurrentIP(); + if (currentIP != null) { + print('测试本地服务器: $currentIP:$_defaultPort'); + return await checkDeviceSharing(currentIP); + } + return false; + } catch (e) { + print('测试本地服务器失败: $e'); + return false; + } + } + +} + +/// 附近设备信息 +class NearbyDevice { + final String ip; + final bool isReachable; + final bool hasSharing; + final DateTime lastSeen; + + const NearbyDevice({ + required this.ip, + required this.isReachable, + required this.hasSharing, + required this.lastSeen, + }); + + /// 从JSON创建NearbyDevice实例 + factory NearbyDevice.fromJson(Map json) { + return NearbyDevice( + ip: json['ip'] as String, + isReachable: json['isReachable'] as bool, + hasSharing: json['hasSharing'] as bool, + lastSeen: DateTime.parse(json['lastSeen'] as String), + ); + } + + /// 转换为JSON + Map toJson() { + return { + 'ip': ip, + 'isReachable': isReachable, + 'hasSharing': hasSharing, + 'lastSeen': lastSeen.toIso8601String(), + }; + } + + /// 创建一个更新在线状态的副本 + NearbyDevice copyWith({ + String? ip, + bool? isReachable, + bool? hasSharing, + DateTime? lastSeen, + }) { + return NearbyDevice( + ip: ip ?? this.ip, + isReachable: isReachable ?? this.isReachable, + hasSharing: hasSharing ?? this.hasSharing, + lastSeen: lastSeen ?? this.lastSeen, + ); + } + + @override + String toString() { + return 'NearbyDevice(ip: $ip, isReachable: $isReachable, hasSharing: $hasSharing, lastSeen: $lastSeen)'; + } +} \ No newline at end of file diff --git a/lib/util/settings_manager.dart b/lib/util/settings_manager.dart index 9070896..7b4681e 100644 --- a/lib/util/settings_manager.dart +++ b/lib/util/settings_manager.dart @@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart'; const String settingKeyFirstOpenApp = "FirstOpenApp"; const String settingKeyHostConfigs = "HostConfigs"; const String settingKeyUseHostFile = "UseHostFileKey"; +const String settingKeyAutoStartEnabled = "AutoStartEnabled"; +const String settingKeyAutoStartHosts = "AutoStartHosts"; class SettingsManager { static final SettingsManager _instance = SettingsManager._internal(); diff --git a/lib/widget/app_bar/home_app_bar.dart b/lib/widget/app_bar/home_app_bar.dart deleted file mode 100644 index 6636dcb..0000000 --- a/lib/widget/app_bar/home_app_bar.dart +++ /dev/null @@ -1,375 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:hosts/enums.dart'; -import 'package:hosts/model/global_settings.dart'; -import 'package:hosts/model/host_file.dart'; -import 'package:hosts/model/simple_host_file.dart'; -import 'package:hosts/page/history_page.dart'; -import 'package:hosts/widget/dialog/copy_multiple_dialog.dart'; -import 'package:hosts/widget/text_field/search_text_field.dart'; - -class HomeAppBar extends StatelessWidget { - final bool isSave; - final VoidCallback undoHost; - final ValueChanged onOpenFile; - final String searchText; - final ValueChanged onSearchChanged; - final AdvancedSettingsEnum advancedSettingsEnum; - final ValueChanged onSwitchAdvancedSettings; - final EditMode editMode; - final ValueChanged onSwitchMode; - final List hosts; - final Map sortConfig; - final VoidCallback? onDeletePressed; - final bool isCheckedAll; - final ValueChanged onCheckedAllChanged; - final ValueChanged> onSortConfChanged; - final SimpleHostFileHistory? selectHistory; - final List history; - final ValueChanged onSwitchHosts; - final ValueChanged onHistoryChanged; - - const HomeAppBar({ - super.key, - required this.isSave, - required this.onOpenFile, - required this.undoHost, - required this.searchText, - required this.onSearchChanged, - required this.advancedSettingsEnum, - required this.onSwitchAdvancedSettings, - required this.editMode, - required this.onSwitchMode, - required this.hosts, - required this.sortConfig, - required this.onDeletePressed, - required this.isCheckedAll, - required this.onCheckedAllChanged, - required this.onSortConfChanged, - required this.selectHistory, - required this.history, - required this.onSwitchHosts, - required this.onHistoryChanged, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Container( - height: 58, - padding: const EdgeInsets.symmetric(horizontal: 5), - child: Row( - children: [ - Expanded( - child: Row( - children: [ - if (GlobalSettings().isSimple) - IconButton( - onPressed: () async { - FilePickerResult? result = - await FilePicker.platform.pickFiles(); - if (result == null) { - return; - } - if (!isSave) { - ScaffoldMessenger.of(context) - .removeCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - AppLocalizations.of(context)!.error_not_save), - action: SnackBarAction( - label: AppLocalizations.of(context)!.abort, - onPressed: () => pickFile(context, result), - ), - )); - - return; - } - - pickFile(context, result); - }, - icon: const Icon(Icons.file_open_outlined), - tooltip: AppLocalizations.of(context)!.open_file, - ) - else - IconButton( - onPressed: () { - onSwitchAdvancedSettings( - advancedSettingsEnum == AdvancedSettingsEnum.Close - ? AdvancedSettingsEnum.Open - : AdvancedSettingsEnum.Close, - ); - }, - icon: const Icon(Icons.menu), - tooltip: - AppLocalizations.of(context)!.advanced_settings, - ), - _buildEditModeButton(context), - const SizedBox(width: 10), - if (editMode == EditMode.Table) - Flexible( - child: Container( - constraints: const BoxConstraints( - maxWidth: 430, - minWidth: 100, - ), - child: SearchTextField( - text: searchText, - onChanged: onSearchChanged, - ), - ), - ), - ], - ), - ), - const SizedBox(width: 32), - Row( - children: [ - batchGroupButton(context), - if (history.isNotEmpty) - IconButton( - onPressed: () async { - SimpleHostFileHistory? resultHistory = - await showModalBottomSheet( - context: context, - builder: (BuildContext context) => HistoryPage( - selectHistory: selectHistory, history: history), - ); - if (resultHistory == null) { - onHistoryChanged(null); - return; - } - - if (!isSave) { - ScaffoldMessenger.of(context).removeCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - AppLocalizations.of(context)!.error_not_save), - action: SnackBarAction( - label: AppLocalizations.of(context)!.abort, - onPressed: () => onHistoryChanged(resultHistory), - ), - )); - - return; - } - onHistoryChanged(resultHistory); - }, - icon: const Icon(Icons.history), - ), - if (!isSave) - IconButton( - onPressed: undoHost, - icon: const Icon(Icons.undo), - tooltip: AppLocalizations.of(context)!.reduction, - ), - buildMoreButton(context) - ], - ) - ], - ), - ), - if (editMode == EditMode.Table) - Table( - columnWidths: MediaQuery.of(context).size.width < 600 - ? const { - 0: FixedColumnWidth(50), - 2: FlexColumnWidth(1), - 3: FlexColumnWidth(1), - 5: FlexColumnWidth(1), - } - : const { - 0: FixedColumnWidth(50), - 2: FixedColumnWidth(100), - 3: FlexColumnWidth(2), - 5: FixedColumnWidth(150), - }, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [tableHeader(context)], - ) - ], - ); - } - - IconButton _buildEditModeButton(BuildContext context) { - return IconButton( - onPressed: () { - if (editMode == EditMode.Text) { - onSwitchMode(EditMode.Table); - } else { - onSwitchMode(EditMode.Text); - } - }, - tooltip: editMode == EditMode.Text - ? AppLocalizations.of(context)!.table - : AppLocalizations.of(context)!.text, - icon: Icon( - editMode == EditMode.Text - ? Icons.table_rows_outlined - : Icons.text_snippet_outlined, - ), - ); - } - - TableRow tableHeader(BuildContext context) { - return TableRow( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Checkbox( - value: hosts.isNotEmpty && isCheckedAll, - onChanged: onCheckedAllChanged, - ), - ), - tableHeaderItem("host", AppLocalizations.of(context)!.ip_address), - tableHeaderItem("isUse", AppLocalizations.of(context)!.status), - tableHeaderItem("hosts", AppLocalizations.of(context)!.domain), - tableHeaderItem("description", AppLocalizations.of(context)!.remark), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text(AppLocalizations.of(context)!.action, - style: const TextStyle(fontWeight: FontWeight.bold)), - ), - ], - ); - } - - GestureDetector tableHeaderItem(String columnName, String label) { - return GestureDetector( - onTap: () { - if (sortConfig[columnName] == null) { - sortConfig[columnName] = 1; - } else if (sortConfig[columnName] == 1) { - sortConfig[columnName] = 2; - } else if (sortConfig[columnName] == 2) { - sortConfig[columnName] = null; - } - onSortConfChanged(sortConfig); - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), - if (sortConfig[columnName] == null) - const SizedBox() - else - Icon( - sortConfig[columnName] == 2 - ? Icons.arrow_upward - : Icons.arrow_downward, - size: 16.0, - ), - ], - ), - ), - ); - } - - Widget batchGroupButton(BuildContext context) { - return Row( - children: [ - if (hosts.isNotEmpty && editMode == EditMode.Table) - Switch( - value: true, - onChanged: (value) => onSwitchHosts(true), - ), - if (hosts.isNotEmpty && editMode == EditMode.Table) - Switch( - value: false, - onChanged: (value) => onSwitchHosts(false), - ), - if (hosts.isNotEmpty && editMode == EditMode.Table) - IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => CopyMultipleDialog(hosts: hosts)); - }, - tooltip: AppLocalizations.of(context)!.copy_selected, - icon: const Icon(Icons.copy)), - if (hosts.isNotEmpty && editMode == EditMode.Table) - IconButton( - onPressed: onDeletePressed, - tooltip: AppLocalizations.of(context)!.delete_selected, - icon: const Icon(Icons.delete_outline)), - ], - ); - } - - void pickFile(BuildContext context, FilePickerResult result) { - if (result.files.first.size > 10 * 1024 * 1024) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)!.error_open_file_size))); - return; - } - - try { - String path = ""; - try { - path = result.files.single.path ?? ""; - } catch (e) { - path = ""; - } - final Uint8List? bytes = result.files.first.bytes; - if (path.isNotEmpty && bytes == null) { - onOpenFile(File(path).readAsStringSync()); - } - - if (path.isEmpty && bytes != null) { - onOpenFile(utf8.decode(bytes)); - } - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)!.error_open_file))); - } - } - - Widget buildMoreButton(BuildContext context) { - return PopupMenuButton(onSelected: (value) { - switch (value) { - case 1: - showAboutDialog( - context: context, - applicationVersion: '1.5.0', - applicationIcon: Image.asset( - "assets/icon/logo.png", - width: 50, - height: 50, - ), - children: [ - Text(AppLocalizations.of(context)!.about_description), - const SizedBox(height: 10), - const Text('Developed by Webb.'), - ], - ); - break; - default: - break; - } - }, itemBuilder: (BuildContext context) { - final List> list = [ - {"text": AppLocalizations.of(context)!.about, "value": 1}, - ]; - - return list.map((item) { - return PopupMenuItem( - value: int.parse(item["value"].toString()), - child: Row( - children: [ - if (item["icon"] != null) Icon(item["icon"]! as IconData), - SizedBox(width: item["icon"] != null ? 8 : 32), - Text(item["text"]!.toString()), - ], - ), - ); - }).toList(); - }); - } -} diff --git a/lib/widget/dialog/access_device_dialog.dart b/lib/widget/dialog/access_device_dialog.dart new file mode 100644 index 0000000..d9f9abb --- /dev/null +++ b/lib/widget/dialog/access_device_dialog.dart @@ -0,0 +1,501 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/util/file_manager.dart'; +import 'package:hosts/util/settings_manager.dart'; +import 'package:hosts/util/device_api_cache.dart'; +import 'package:hosts/util/nearby_devices_scanner.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +Future accessDeviceDialog(BuildContext context, NearbyDevice device) { + return showDialog( + context: context, + builder: (BuildContext context) { + return AccessDeviceDialog(device: device); + }, + ); +} + +class AccessDeviceDialog extends StatefulWidget { + final NearbyDevice device; + + const AccessDeviceDialog({ + super.key, + required this.device, + }); + + @override + State createState() => _AccessDeviceDialogState(); +} + +class _AccessDeviceDialogState extends State { + List availableHosts = []; + List existingHostFiles = []; + late List selectedItems; + bool isAllSelected = false; + bool isLoading = false; + bool hasError = false; + String? errorMessage; + Map historyCount = {}; + + @override + void initState() { + super.initState(); + selectedItems = []; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _loadExistingHostFiles(); + _loadDeviceHosts(); + } + + Future _loadExistingHostFiles() async { + try { + final String defaultHostsText = AppLocalizations.of(context)!.default_hosts_text; + final SettingsManager settingsManager = SettingsManager(); + final List hostConfigs = + await settingsManager.getList(settingKeyHostConfigs); + + final List hosts = []; + for (final config in hostConfigs) { + final hostFile = SimpleHostFile.fromJson(config); + if (hostFile.fileName == "system") { + hostFile.remark = defaultHostsText; + } + hosts.add(hostFile); + } + + setState(() { + existingHostFiles = hosts; + }); + } catch (e) { + // Ignore errors, use empty list + setState(() { + existingHostFiles = []; + }); + } + } + + Future _loadDeviceHosts() async { + setState(() { + isLoading = true; + hasError = false; + errorMessage = null; + }); + + try { + final String defaultHostsText = AppLocalizations.of(context)!.default_hosts_text; + + // Refresh local file list + await _loadExistingHostFiles(); + + // Get device data directly, no caching + final hostConfigs = await DeviceApiCache.getCachedDeviceData(widget.device.ip); + + final List hosts = []; + final Map historyCounts = {}; + + for (final config in hostConfigs) { + final hostFile = SimpleHostFile.fromJson(config); + if (hostFile.fileName == "system") { + hostFile.remark = defaultHostsText; + } + hosts.add(hostFile); + + // 获取每个hosts文件的历史记录数量 + try { + final historyList = await DeviceApiCache.getCachedHostsFileHistory( + widget.device.ip, + hostFile.fileName, + ); + historyCounts[hostFile.fileName] = historyList.length; + } catch (e) { + historyCounts[hostFile.fileName] = 0; + } + } + + setState(() { + availableHosts = hosts; + selectedItems = List.generate(hosts.length, (index) => false); + historyCount = historyCounts; + isLoading = false; + }); + } catch (e) { + setState(() { + isLoading = false; + hasError = true; + errorMessage = e.toString(); + }); + } + } + + void _toggleSelectAll() { + setState(() { + isAllSelected = !isAllSelected; + for (int i = 0; i < selectedItems.length; i++) { + selectedItems[i] = isAllSelected; + } + }); + } + + void _toggleItem(int index) { + setState(() { + selectedItems[index] = !selectedItems[index]; + isAllSelected = selectedItems.every((item) => item); + }); + } + + Future _accessSelected() async { + final List selectedHosts = []; + for (int i = 0; i < availableHosts.length; i++) { + if (selectedItems[i]) { + selectedHosts.add(availableHosts[i]); + } + } + + if (selectedHosts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.error_null_data)), + ); + return; + } + + // 获取本地化字符串,在关闭对话框之前 + final String importSuccessMessage = + AppLocalizations.of(context)!.import_success; + final String remoteFilesText = + AppLocalizations.of(context)!.remote_files; + final String noImportableContentText = + AppLocalizations.of(context)!.no_importable_content; + final String importFailMessage = + AppLocalizations.of(context)!.error_save_fail; + final ScaffoldMessengerState scaffoldMessenger = + ScaffoldMessenger.of(context); + + Navigator.of(context).pop(); + + try { + final FileManager fileManager = FileManager(); + final SettingsManager settingsManager = SettingsManager(); + final List importedFiles = []; + + for (final hostFile in selectedHosts) { + // 获取远程文件内容 + final String? remoteContent = + await DeviceApiCache.getCachedHostsFileContent( + widget.device.ip, + hostFile.fileName, + ); + + if (remoteContent != null && remoteContent.isNotEmpty) { + // 直接使用原始文件名 + final String fileName = hostFile.fileName; + + // 创建本地文件 + await fileManager.createHosts(fileName); + + // 写入远程内容到本地文件 + final String localFilePath = + await fileManager.getHostsFilePath(fileName); + await File(localFilePath).writeAsString(remoteContent); + + // 导入hosts历史记录 + try { + + // 保存历史文件到本地,使用File直接写入 + final Directory historyDirPath = Directory(p.join( + (await getApplicationSupportDirectory()).path, + fileName, + 'history' + )); + + if (!historyDirPath.existsSync()) { + historyDirPath.createSync(); + } + + final List> historyList = + await DeviceApiCache.getCachedHostsFileHistory( + widget.device.ip, + fileName, + ); + + for (final historyItem in historyList) { + final String historyFileName = historyItem['id'] ?? ''; + if (historyFileName.isNotEmpty) { + // 获取历史文件内容 + final String? historyContent = + await DeviceApiCache.getCachedHostsFileHistoryContent( + widget.device.ip, + fileName, + historyFileName, + ); + + if (historyContent != null && historyContent.isNotEmpty) { + // 直接写入历史文件,文件名保持与远程一致 + final String historyFilePath = p.join(historyDirPath.path, historyFileName); + await File(historyFilePath).writeAsString(historyContent); + } + } + } + } catch (e) { + print('导入hosts历史失败 $fileName: $e'); + // 历史导入失败不影响主文件导入,继续处理 + } + + // 创建本地SimpleHostFile对象 + final SimpleHostFile localHostFile = SimpleHostFile( + fileName: fileName, + remark: hostFile.remark, + ); + + importedFiles.add(localHostFile); + } + } + + if (importedFiles.isNotEmpty) { + // 获取当前配置 + List hostConfigs = + await settingsManager.getList(settingKeyHostConfigs); + + // 添加导入的文件到配置中 + for (final importedFile in importedFiles) { + // 检查是否已存在相同fileName的配置 + int existingIndex = hostConfigs.indexWhere( + (config) => config['fileName'] == importedFile.fileName); + + if (existingIndex >= 0) { + // 如果存在,则覆盖 + hostConfigs[existingIndex] = importedFile.toJson(); + } else { + // 如果不存在,则添加 + hostConfigs.add(importedFile.toJson()); + } + } + + // 保存到settingsManager + await settingsManager.setList(settingKeyHostConfigs, hostConfigs); + + scaffoldMessenger.showSnackBar( + SnackBar( + content: + Text('$importSuccessMessage ${importedFiles.length}$remoteFilesText')), + ); + } else { + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(noImportableContentText)), + ); + } + } catch (e) { + scaffoldMessenger.showSnackBar( + SnackBar(content: Text('$importFailMessage: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + final selectedCount = selectedItems.where((item) => item).length; + + return AlertDialog( + title: Row( + children: [ + Icon(Icons.computer, color: Colors.green), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppLocalizations.of(context)!.import_remote_hosts), + Text( + widget.device.ip, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + content: SizedBox( + width: 500, + height: 450, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 设备信息 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.share, + color: Colors.green, + size: 20, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.sharing_enabled, + style: TextStyle( + color: Colors.green, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + IconButton( + icon: Icon(Icons.refresh), + onPressed: isLoading ? null : _loadDeviceHosts, + tooltip: AppLocalizations.of(context)!.refresh, + ), + ], + ), + ), + const SizedBox(height: 16), + + if (isLoading) + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text(AppLocalizations.of(context)!.getting_remote_hosts), + ], + ), + ), + ) + else if (hasError) + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red), + SizedBox(height: 16), + Text(AppLocalizations.of(context)!.connection_failed), + if (errorMessage != null) ...[ + SizedBox(height: 8), + Text( + errorMessage!, + style: + TextStyle(color: Colors.grey[600], fontSize: 12), + textAlign: TextAlign.center, + ), + ], + SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _loadDeviceHosts, + icon: Icon(Icons.refresh), + label: Text(AppLocalizations.of(context)!.retry), + ), + ], + ), + ), + ) + else if (availableHosts.isNotEmpty) ...[ + // 全选/反选按钮和统计 + Row( + children: [ + Checkbox( + value: isAllSelected, + onChanged: (bool? value) => _toggleSelectAll(), + ), + Text(AppLocalizations.of(context)!.select_all), + const Spacer(), + Text('$selectedCount/${availableHosts.length}'), + ], + ), + const Divider(), + // hosts文件列表 + Expanded( + child: ListView.builder( + itemCount: availableHosts.length, + itemBuilder: (context, index) { + final host = availableHosts[index]; + final bool isExisting = existingHostFiles.any( + (existingFile) => + existingFile.fileName == host.fileName); + + return ListTile( + leading: Icon( + Icons.description, + color: Theme.of(context).colorScheme.primary, + ), + trailing: Checkbox( + value: selectedItems[index], + onChanged: (bool? value) => _toggleItem(index), + ), + title: Text(host.remark), + subtitle: (historyCount[host.fileName] != null && historyCount[host.fileName]! > 0) || isExisting + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (historyCount[host.fileName] != null && historyCount[host.fileName]! > 0) + Text( + '${AppLocalizations.of(context)!.history_count}: ${historyCount[host.fileName]}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + if (isExisting) + Text( + AppLocalizations.of(context)!.will_overwrite, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ], + ) + : null, + onTap: () => _toggleItem(index), + ); + }, + ), + ), + ] else ...[ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.folder_open, size: 48, color: Colors.grey), + SizedBox(height: 16), + Text(AppLocalizations.of(context)!.no_hosts_files_found), + Text( + AppLocalizations.of(context)!.device_no_shared_files, + style: TextStyle(color: Colors.grey[600]), + ), + ], + ), + ), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: (selectedCount > 0 && !isLoading) ? _accessSelected : null, + child: Text(AppLocalizations.of(context)!.import), + ), + ], + ); + } +} diff --git a/lib/widget/dialog/api_documentation_dialog.dart b/lib/widget/dialog/api_documentation_dialog.dart new file mode 100644 index 0000000..5cb3813 --- /dev/null +++ b/lib/widget/dialog/api_documentation_dialog.dart @@ -0,0 +1,729 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/util/device_api_cache.dart'; +import 'package:hosts/widget/dialog/qr_code_dialog.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// API文档弹窗 +class ApiDocumentationDialog extends StatefulWidget { + final String deviceIp; + final int port; + + const ApiDocumentationDialog({ + super.key, + required this.deviceIp, + this.port = 1204, + }); + + @override + State createState() => _ApiDocumentationDialogState(); +} + +class _ApiDocumentationDialogState extends State { + List> hostsFiles = []; + bool isLoading = true; + bool hasError = false; + String? errorMessage; + bool isBasicEndpointsExpanded = false; + Map hostFileExpansionState = {}; + Map>> hostFileHistories = {}; + + @override + void initState() { + super.initState(); + _loadHostsFiles(); + } + + Future _loadHostsFiles() async { + try { + setState(() { + isLoading = true; + hasError = false; + errorMessage = null; + }); + + final data = await DeviceApiCache.getCachedDeviceData(widget.deviceIp); + + // 为每个hosts文件获取历史记录 + final Map>> histories = {}; + for (final hostFile in data) { + final fileName = hostFile['fileName'] as String?; + if (fileName != null) { + try { + final history = await DeviceApiCache.getCachedHostsFileHistory( + widget.deviceIp, + fileName, + ); + histories[fileName] = history; + } catch (e) { + // 如果获取历史失败,设置为空列表 + histories[fileName] = []; + } + } + } + + setState(() { + hostsFiles = data; + hostFileHistories = histories; + isLoading = false; + }); + } catch (e) { + setState(() { + isLoading = false; + hasError = true; + errorMessage = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + final baseUrl = 'http://${widget.deviceIp}:${widget.port}'; + final screenSize = MediaQuery.of(context).size; + + return AlertDialog( + insetPadding: EdgeInsets.all(16), + title: Row( + children: [ + Icon(Icons.api, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.api_docs), + const Spacer(), + IconButton( + icon: Icon(Icons.refresh), + onPressed: isLoading ? null : _loadHostsFiles, + tooltip: AppLocalizations.of(context)!.refresh, + ), + ], + ), + content: SizedBox( + width: screenSize.width, + height: screenSize.height, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 服务器地址信息 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.computer, + color: Colors.green, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.server_address, + style: + Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + baseUrl, + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.open_in_browser), + onPressed: () => _openInBrowser(baseUrl), + tooltip: AppLocalizations.of(context)!.open_in_browser, + ), + IconButton( + icon: Icon(Icons.qr_code), + onPressed: () => _showQrCode(context, baseUrl), + tooltip: + AppLocalizations.of(context)!.show_qr_code_tooltip, + ), + IconButton( + icon: Icon(Icons.copy), + onPressed: () => _copyToClipboard(context, baseUrl), + tooltip: AppLocalizations.of(context)!.copy_url, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + + // API端点列表 + Text( + '${AppLocalizations.of(context)!.api_endpoints}:', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + + Expanded( + child: isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text(AppLocalizations.of(context)! + .getting_device_info), + ], + ), + ) + : hasError + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, + size: 48, color: Colors.red), + SizedBox(height: 16), + Text(AppLocalizations.of(context)! + .get_device_info_failed), + if (errorMessage != null) ...[ + SizedBox(height: 8), + Text( + errorMessage!, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _loadHostsFiles, + icon: Icon(Icons.refresh), + label: + Text(AppLocalizations.of(context)!.retry), + ), + ], + ), + ) + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 基础API端点 + _buildBasicEndpoints(context, baseUrl), + + const SizedBox(height: 16), + + // Hosts文件相关API + if (hostsFiles.isNotEmpty) ...[ + Text( + AppLocalizations.of(context)! + .available_hosts_files_api, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + _buildHostsFilesLayout( + context, baseUrl, screenSize), + ] else ...[ + Container( + padding: const EdgeInsets.all(16), + child: Text( + AppLocalizations.of(context)! + .device_no_hosts_files, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + ], + ); + } + + /// 构建hosts文件布局 - 根据屏幕大小决定使用List还是GridView + Widget _buildHostsFilesLayout( + BuildContext context, String baseUrl, Size screenSize) { + // 判断屏幕宽度,决定使用什么布局 + final isWideScreen = screenSize.width > 1200; + + if (isWideScreen && hostsFiles.length >= 2) { + // 宽屏且文件数量多时使用StaggeredGridView + return StaggeredGrid.count( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 8, + children: hostsFiles.map((hostFile) { + return StaggeredGridTile.fit( + crossAxisCellCount: 1, + child: _buildHostFileEndpoints( + context, + baseUrl, + hostFile['fileName'] ?? '', + hostFile['remark'] ?? '', + ), + ); + }).toList(), + ); + } else { + // 窄屏或文件数量少时使用Column + return Column( + children: hostsFiles + .map((hostFile) => _buildHostFileEndpoints( + context, + baseUrl, + hostFile['fileName'] ?? '', + hostFile['remark'] ?? '', + )) + .toList(), + ); + } + } + + /// 构建基础API端点 + Widget _buildBasicEndpoints(BuildContext context, String baseUrl) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surface, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () { + setState(() { + isBasicEndpointsExpanded = !isBasicEndpointsExpanded; + }); + }, + child: Row( + children: [ + Icon(Icons.api, + size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of(context)!.basic_api, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + AnimatedRotation( + turns: isBasicEndpointsExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.expand_more, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + height: isBasicEndpointsExpanded ? null : 0, + child: isBasicEndpointsExpanded + ? Column( + children: [ + const SizedBox(height: 8), + _buildApiEndpoint( + context, + 'GET', + '/', + AppLocalizations.of(context)!.server_status, + baseUrl, + isCompact: true, + ), + _buildApiEndpoint( + context, + 'GET', + '/api/hosts', + AppLocalizations.of(context)!.get_all_hosts_files, + baseUrl, + isCompact: true, + ), + ], + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + /// 构建hosts文件相关的API端点 + Widget _buildHostFileEndpoints( + BuildContext context, + String baseUrl, + String fileName, + String remark, + ) { + final displayName = remark.isNotEmpty ? '$remark ($fileName)' : fileName; + final isExpanded = hostFileExpansionState[fileName] ?? true; + final histories = hostFileHistories[fileName] ?? []; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surface, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () { + setState(() { + hostFileExpansionState[fileName] = !isExpanded; + }); + }, + child: Row( + children: [ + Icon(Icons.description, + size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + displayName, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + if (histories.isNotEmpty) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${histories.length}${AppLocalizations.of(context)!.history_count_suffix}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + AnimatedRotation( + turns: isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.expand_more, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + height: isExpanded ? null : 0, + child: isExpanded + ? Column( + children: [ + const SizedBox(height: 8), + + // 获取hosts文件内容 + _buildApiEndpoint( + context, + 'GET', + '/api/hosts/$fileName', + '${AppLocalizations.of(context)!.get_content_prefix} $displayName ${AppLocalizations.of(context)!.get_content_suffix}', + baseUrl, + isCompact: true, + ), + + // 只有当有历史记录时才显示具体的历史记录API端点 + if (histories.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${AppLocalizations.of(context)!.history_content} (${histories.length}${AppLocalizations.of(context)!.history_count_suffix}):', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + fontWeight: FontWeight.w500, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + ...histories.take(5).map((history) { + final historyId = + history['fileName'] ?? history['id'] ?? ''; + if (historyId.isNotEmpty) { + return _buildApiEndpoint( + context, + 'GET', + '/api/hosts/$fileName/history/$historyId', + '${AppLocalizations.of(context)!.get_history_content_prefix} $historyId ${AppLocalizations.of(context)!.get_history_content_suffix}', + baseUrl, + isCompact: true, + isHistoryEndpoint: true, + ); + } + return const SizedBox.shrink(); + }), + if (histories.length > 5) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${AppLocalizations.of(context)!.more_history_records}${histories.length - 5}${AppLocalizations.of(context)!.more_history_records_suffix}', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + ], + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + /// 构建API端点项 + Widget _buildApiEndpoint( + BuildContext context, + String method, + String path, + String description, + String baseUrl, { + bool isCompact = false, + bool isHistoryEndpoint = false, + }) { + Color methodColor; + switch (method) { + case 'GET': + methodColor = Theme.of(context).colorScheme.primary; + break; + case 'POST': + methodColor = Theme.of(context).colorScheme.secondary; + break; + case 'PUT': + methodColor = Theme.of(context).colorScheme.tertiary; + break; + case 'DELETE': + methodColor = Theme.of(context).colorScheme.error; + break; + default: + methodColor = Theme.of(context).colorScheme.outline; + } + + final fullUrl = baseUrl + path; + + return Container( + margin: EdgeInsets.symmetric(vertical: isCompact ? 2 : 4), + padding: EdgeInsets.all(isCompact ? 8 : 12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!.withValues(alpha: 0.5)), + borderRadius: BorderRadius.circular(6), + color: isHistoryEndpoint + ? Colors.blue[25] + : (isCompact ? Colors.grey[50] : null), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: methodColor, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + method, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + path, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: + Icon(Icons.open_in_browser, size: isCompact ? 16 : 18), + onPressed: () => _openInBrowser(fullUrl), + tooltip: AppLocalizations.of(context)!.open_in_browser, + constraints: BoxConstraints( + minWidth: isCompact ? 28 : 32, + minHeight: isCompact ? 28 : 32, + ), + padding: EdgeInsets.all(isCompact ? 2 : 4), + ), + IconButton( + icon: Icon(Icons.qr_code, size: isCompact ? 16 : 18), + onPressed: () => _showQrCode(context, fullUrl), + tooltip: AppLocalizations.of(context)!.show_qr_code_tooltip, + constraints: BoxConstraints( + minWidth: isCompact ? 28 : 32, + minHeight: isCompact ? 28 : 32, + ), + padding: EdgeInsets.all(isCompact ? 2 : 4), + ), + IconButton( + icon: Icon(Icons.copy, size: isCompact ? 16 : 18), + onPressed: () => _copyToClipboard(context, fullUrl), + tooltip: AppLocalizations.of(context)!.copy_url_tooltip, + constraints: BoxConstraints( + minWidth: isCompact ? 28 : 32, + minHeight: isCompact ? 28 : 32, + ), + padding: EdgeInsets.all(isCompact ? 2 : 4), + ), + ], + ), + ], + ), + if (!isCompact) ...[ + const SizedBox(height: 4), + Text( + description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 4), + Text( + fullUrl, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + /// 在浏览器中打开URL + Future _openInBrowser(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + } + + /// 显示二维码 + void _showQrCode(BuildContext context, String url) { + QrCodeDialog.show( + context, + url: url, + title: AppLocalizations.of(context)!.scan_qr_code_to_access, + ); + } + + /// 复制到剪贴板 + Future _copyToClipboard(BuildContext context, String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.server_url_copied), + duration: Duration(seconds: 2), + ), + ); + } + } +} + +/// 显示API文档对话框的便捷函数 +Future showApiDocumentationDialog( + BuildContext context, + String deviceIp, { + int port = 1204, +}) { + return showDialog( + context: context, + builder: (BuildContext context) { + return ApiDocumentationDialog( + deviceIp: deviceIp, + port: port, + ); + }, + ); +} diff --git a/lib/widget/dialog/copy_dialog.dart b/lib/widget/dialog/copy_dialog.dart index f6baca8..5b4ae27 100644 --- a/lib/widget/dialog/copy_dialog.dart +++ b/lib/widget/dialog/copy_dialog.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; import 'package:hosts/model/host_file.dart'; Future copyDialog( diff --git a/lib/widget/dialog/copy_multiple_dialog.dart b/lib/widget/dialog/copy_multiple_dialog.dart index 49dccb9..dc0f35f 100644 --- a/lib/widget/dialog/copy_multiple_dialog.dart +++ b/lib/widget/dialog/copy_multiple_dialog.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; import 'package:hosts/model/host_file.dart'; class CopyMultipleDialog extends StatelessWidget { diff --git a/lib/widget/dialog/dialog.dart b/lib/widget/dialog/dialog.dart index 2ea454a..5eedca7 100644 --- a/lib/widget/dialog/dialog.dart +++ b/lib/widget/dialog/dialog.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; Future hostConfigDialog(BuildContext context, [String defaultText = ""]) { diff --git a/lib/widget/dialog/export_hosts_dialog.dart b/lib/widget/dialog/export_hosts_dialog.dart new file mode 100755 index 0000000..29648ab --- /dev/null +++ b/lib/widget/dialog/export_hosts_dialog.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/util/file_manager.dart'; + +Future exportHostsDialog( + BuildContext context, List hostFiles) { + return showDialog( + context: context, + builder: (BuildContext context) { + return ExportHostsDialog(hostFiles: hostFiles); + }, + ); +} + +class ExportHostsDialog extends StatefulWidget { + final List hostFiles; + + const ExportHostsDialog({super.key, required this.hostFiles}); + + @override + State createState() => _ExportHostsDialogState(); +} + +class _ExportHostsDialogState extends State { + late List selectedItems; + bool isAllSelected = false; + + @override + void initState() { + super.initState(); + selectedItems = List.generate(widget.hostFiles.length, (index) => false); + } + + void _toggleSelectAll() { + setState(() { + isAllSelected = !isAllSelected; + for (int i = 0; i < selectedItems.length; i++) { + selectedItems[i] = isAllSelected; + } + }); + } + + void _toggleItem(int index) { + setState(() { + selectedItems[index] = !selectedItems[index]; + isAllSelected = selectedItems.every((item) => item); + }); + } + + Future _exportSelected() async { + final List selectedHostFiles = []; + for (int i = 0; i < widget.hostFiles.length; i++) { + if (selectedItems[i]) { + selectedHostFiles.add(widget.hostFiles[i]); + } + } + + if (selectedHostFiles.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.select_hosts_to_export)), + ); + } + return; + } + + // 获取本地化字符串,在关闭对话框之前 + final String exportDataTitle = AppLocalizations.of(context)!.export_data; + final String exportSuccessMessage = AppLocalizations.of(context)!.export_success; + + // 获取ScaffoldMessenger,在关闭对话框之前 + final ScaffoldMessengerState scaffoldMessenger = ScaffoldMessenger.of(context); + + Navigator.of(context).pop(); + + final FileManager fileManager = FileManager(); + bool success = false; + + try { + // 统一使用批量导出方法 + success = await fileManager.exportMultipleHostFiles( + selectedHostFiles, + exportDataTitle, + ); + + if (success) { + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(exportSuccessMessage)), + ); + } + } catch (e) { + scaffoldMessenger.showSnackBar( + SnackBar(content: Text('${AppLocalizations.of(context)!.export_failed}: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + final selectedCount = selectedItems.where((item) => item).length; + + return AlertDialog( + title: Text(AppLocalizations.of(context)!.export_data), + content: SizedBox( + width: 500, + height: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 全选/反选按钮和统计 + Row( + children: [ + Checkbox( + value: isAllSelected, + onChanged: (bool? value) => _toggleSelectAll(), + ), + Text(AppLocalizations.of(context)!.select_all), + const Spacer(), + Text('${AppLocalizations.of(context)!.selected_count}: $selectedCount/${widget.hostFiles.length}'), + ], + ), + const Divider(), + // hosts文件列表 + Expanded( + child: ListView.builder( + itemCount: widget.hostFiles.length, + itemBuilder: (context, index) { + final hostFile = widget.hostFiles[index]; + return ListTile( + trailing: Checkbox( + value: selectedItems[index], + onChanged: (bool? value) => _toggleItem(index), + ), + title: Text(hostFile.remark), + onTap: () => _toggleItem(index), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: selectedCount > 0 ? _exportSelected : null, + child: Text(AppLocalizations.of(context)!.export), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widget/dialog/import_hosts_dialog.dart b/lib/widget/dialog/import_hosts_dialog.dart new file mode 100755 index 0000000..d3b0834 --- /dev/null +++ b/lib/widget/dialog/import_hosts_dialog.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/util/file_manager.dart'; +import 'package:hosts/util/settings_manager.dart'; + +Future importHostsDialog( + BuildContext context, List existingHostFiles, {VoidCallback? onImportSuccess}) { + return showDialog( + context: context, + builder: (BuildContext context) { + return ImportHostsDialog(existingHostFiles: existingHostFiles, onImportSuccess: onImportSuccess); + }, + ); +} + +class ImportHostsDialog extends StatefulWidget { + final List existingHostFiles; + final VoidCallback? onImportSuccess; + + const ImportHostsDialog({super.key, required this.existingHostFiles, this.onImportSuccess}); + + @override + State createState() => _ImportHostsDialogState(); +} + +class _ImportHostsDialogState extends State { + List importableHosts = []; + late List selectedItems; + bool isAllSelected = false; + bool isLoading = false; + String? selectedFilePath; + + @override + void initState() { + super.initState(); + selectedItems = []; + } + + Future _selectFile() async { + try { + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['zip'], + allowMultiple: false, + ); + + if (result != null && result.files.single.path != null) { + setState(() { + isLoading = true; + selectedFilePath = result.files.single.path; + importableHosts = []; + selectedItems = []; + }); + + final FileManager fileManager = FileManager(); + final List hosts = + await fileManager.parseImportFile(selectedFilePath!); + + setState(() { + importableHosts = hosts; + selectedItems = List.generate(hosts.length, (index) => false); + isLoading = false; + }); + } + } catch (e) { + setState(() { + isLoading = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${AppLocalizations.of(context)!.error_open_file}: $e')), + ); + } + } + } + + void _toggleSelectAll() { + setState(() { + isAllSelected = !isAllSelected; + for (int i = 0; i < selectedItems.length; i++) { + selectedItems[i] = isAllSelected; + } + }); + } + + void _toggleItem(int index) { + setState(() { + selectedItems[index] = !selectedItems[index]; + isAllSelected = selectedItems.every((item) => item); + }); + } + + Future _importSelected() async { + if (selectedFilePath == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.error_null_data)), + ); + return; + } + + final List selectedHosts = []; + for (int i = 0; i < importableHosts.length; i++) { + if (selectedItems[i]) { + selectedHosts.add(importableHosts[i]); + } + } + + if (selectedHosts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.error_null_data)), + ); + return; + } + + // 获取本地化字符串和ScaffoldMessenger,在关闭对话框之前 + final String importSuccessMessage = AppLocalizations.of(context)!.import_success; + final String importFailMessage = AppLocalizations.of(context)!.error_save_fail; + final ScaffoldMessengerState scaffoldMessenger = ScaffoldMessenger.of(context); + + Navigator.of(context).pop(); + + try { + + final FileManager fileManager = FileManager(); + final SettingsManager settingsManager = SettingsManager(); + final List existingFileNames = + widget.existingHostFiles.map((e) => e.fileName).toList(); + + // 导入文件 + final List importedFiles = await fileManager.importSelectedHosts( + selectedFilePath!, + selectedHosts, + existingFileNames, + ); + + if (importedFiles.isNotEmpty) { + // 获取当前配置 + List hostConfigs = await settingsManager.getList(settingKeyHostConfigs); + + // 添加或更新导入的文件到配置中 + for (final importedFile in importedFiles) { + // 查找是否已存在相同fileName的配置 + int existingIndex = hostConfigs.indexWhere((config) => + config['fileName'] == importedFile.fileName); + + if (existingIndex >= 0) { + // 如果存在,则覆盖 + hostConfigs[existingIndex] = importedFile.toJson(); + } else { + // 如果不存在,则添加 + hostConfigs.add(importedFile.toJson()); + } + } + + // 保存到settingsManager + await settingsManager.setList(settingKeyHostConfigs, hostConfigs); + + scaffoldMessenger.showSnackBar( + SnackBar(content: Text('$importSuccessMessage ${importedFiles.length}')), + ); + + // 调用成功回调来刷新数据 + widget.onImportSuccess?.call(); + } else { + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(importFailMessage)), + ); + } + } catch (e) { + scaffoldMessenger.showSnackBar( + SnackBar(content: Text('$importFailMessage: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + final selectedCount = selectedItems.where((item) => item).length; + + return AlertDialog( + title: Text(AppLocalizations.of(context)!.import_data), + content: SizedBox( + width: 500, + height: 450, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 文件选择按钮 + Row( + children: [ + ElevatedButton.icon( + onPressed: isLoading ? null : _selectFile, + icon: Icon(Icons.file_open), + label: Text(AppLocalizations.of(context)!.open_file), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + selectedFilePath != null + ? selectedFilePath!.split('/').last + : AppLocalizations.of(context)!.error_null_data, + style: Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 16), + + if (isLoading) + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('${AppLocalizations.of(context)!.file_processing}...'), + ], + ), + ), + ) + else if (importableHosts.isNotEmpty) ...[ + // 全选/反选按钮和统计 + Row( + children: [ + Checkbox( + value: isAllSelected, + onChanged: (bool? value) => _toggleSelectAll(), + ), + Text(AppLocalizations.of(context)!.select_all), + const Spacer(), + Text('$selectedCount/${importableHosts.length}'), + ], + ), + const Divider(), + // hosts文件列表 + Expanded( + child: ListView.builder( + itemCount: importableHosts.length, + itemBuilder: (context, index) { + final host = importableHosts[index]; + final bool isExisting = widget.existingHostFiles.any((existingFile) => + existingFile.fileName == host.fileName); + + return ListTile( + trailing: Checkbox( + value: selectedItems[index], + onChanged: (bool? value) => _toggleItem(index), + ), + title: Text(host.remark), + subtitle: isExisting + ? Text( + AppLocalizations.of(context)!.will_overwrite, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ) + : null, + onTap: () => _toggleItem(index), + ); + }, + ), + ), + ] else if (selectedFilePath != null) ...[ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.warning, size: 48, color: Colors.orange), + SizedBox(height: 16), + Text(AppLocalizations.of(context)!.error_null_data), + Text(AppLocalizations.of(context)!.error_open_file, + style: TextStyle(color: Colors.grey[600])), + ], + ), + ), + ), + ] else ...[ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.file_upload, size: 48, color: Colors.grey), + SizedBox(height: 16), + Text(AppLocalizations.of(context)!.import_file), + ], + ), + ), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: (selectedCount > 0 && !isLoading) ? _importSelected : null, + child: Text(AppLocalizations.of(context)!.import), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widget/dialog/link_dialog.dart b/lib/widget/dialog/link_dialog.dart index f1ce2d0..31ca974 100644 --- a/lib/widget/dialog/link_dialog.dart +++ b/lib/widget/dialog/link_dialog.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; import 'package:hosts/model/host_file.dart'; Future>?> linkDialog( diff --git a/lib/widget/dialog/qr_code_dialog.dart b/lib/widget/dialog/qr_code_dialog.dart new file mode 100644 index 0000000..50ac33b --- /dev/null +++ b/lib/widget/dialog/qr_code_dialog.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +/// 二维码显示对话框 +class QrCodeDialog extends StatelessWidget { + final String url; + final String? title; + + const QrCodeDialog({ + super.key, + required this.url, + this.title, + }); + + /// 显示二维码对话框 + static void show( + BuildContext context, { + required String url, + String? title, + }) { + showDialog( + context: context, + builder: (BuildContext context) { + return QrCodeDialog( + url: url, + title: title, + ); + }, + ); + } + + /// 复制URL到剪贴板 + void _copyUrl(BuildContext context) { + Clipboard.setData(ClipboardData(text: url)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.server_url_copied), + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(title ?? AppLocalizations.of(context)!.copy_url), + content: SizedBox( + width: 250, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 二维码显示区域 + SizedBox( + width: 200, + height: 200, + child: Center( + child: QrImageView( + data: url, + version: QrVersions.auto, + size: 180.0, + backgroundColor: Colors.transparent, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Theme.of(context).colorScheme.primary, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + const SizedBox(height: 16), + // URL文本显示 + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + url, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton.icon( + onPressed: () { + _copyUrl(context); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.copy, size: 16), + label: Text(AppLocalizations.of(context)!.copy), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widget/dialog/select_hosts_dialog.dart b/lib/widget/dialog/select_hosts_dialog.dart new file mode 100644 index 0000000..8316bac --- /dev/null +++ b/lib/widget/dialog/select_hosts_dialog.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:hosts/l10n/app_localizations.dart'; +import 'package:hosts/model/simple_host_file.dart'; +import 'package:hosts/util/settings_manager.dart'; + +/// 选择要共享的hosts文件对话框 +class SelectHostsDialog extends StatefulWidget { + const SelectHostsDialog({super.key}); + + /// 显示选择hosts文件对话框 + static Future?> show(BuildContext context) { + return showDialog>( + context: context, + builder: (BuildContext context) { + return const SelectHostsDialog(); + }, + ); + } + + @override + State createState() => _SelectHostsDialogState(); +} + +class _SelectHostsDialogState extends State { + final SettingsManager _settingsManager = SettingsManager(); + List _hostFiles = []; + Set _selectedFileNames = {}; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadHostFiles(); + } + + /// 加载所有hosts文件 + Future _loadHostFiles() async { + try { + final List hostConfigs = await _settingsManager.getList(settingKeyHostConfigs); + final List hostFiles = []; + + for (Map config in hostConfigs) { + SimpleHostFile hostFile = SimpleHostFile.fromJson(config); + hostFiles.add(hostFile); + } + + setState(() { + _hostFiles = hostFiles; + // 默认全选所有hosts文件 + _selectedFileNames = hostFiles.map((f) => f.fileName).toSet(); + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + /// 切换文件选择状态 + void _toggleFileSelection(String fileName, bool isSelected) { + setState(() { + if (isSelected) { + _selectedFileNames.add(fileName); + } else { + _selectedFileNames.remove(fileName); + } + }); + } + + /// 切换全选状态 + void _toggleSelectAll(bool selectAll) { + setState(() { + if (selectAll) { + _selectedFileNames = _hostFiles.map((f) => f.fileName).toSet(); + } else { + _selectedFileNames.clear(); + } + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.select_hosts_to_share), + content: SizedBox( + width: 500, + height: 400, + child: _buildContent(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: _selectedFileNames.isEmpty + ? null + : () { + final selectedFiles = _hostFiles + .where((f) => _selectedFileNames.contains(f.fileName)) + .toList(); + Navigator.of(context).pop(selectedFiles); + }, + child: Text('${AppLocalizations.of(context)!.ok} (${_selectedFileNames.length})'), + ), + ], + ); + } + + Widget _buildContent() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + _error = null; + _isLoading = true; + }); + _loadHostFiles(); + }, + child: Text(AppLocalizations.of(context)!.refresh_status), + ), + ], + ), + ); + } + + if (_hostFiles.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.folder_open, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.error_null_data, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + return Column( + children: [ + // 全选/取消全选 + ListTile( + title: Text( + AppLocalizations.of(context)!.select_all, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text('${_hostFiles.length} 个文件'), + leading: Checkbox( + value: _selectedFileNames.length == _hostFiles.length && _hostFiles.isNotEmpty, + tristate: true, + onChanged: (value) { + if (value == null) return; + _toggleSelectAll(value); + }, + ), + onTap: () { + final allSelected = _selectedFileNames.length == _hostFiles.length && _hostFiles.isNotEmpty; + _toggleSelectAll(!allSelected); + }, + contentPadding: EdgeInsets.zero, + ), + const Divider(), + // 文件列表 + Expanded( + child: ListView.builder( + itemCount: _hostFiles.length, + itemBuilder: (context, index) { + final hostFile = _hostFiles[index]; + final isSelected = _selectedFileNames.contains(hostFile.fileName); + + return ListTile( + title: Text( + hostFile.remark.isNotEmpty ? hostFile.remark : hostFile.fileName, + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + leading: Checkbox( + value: isSelected, + onChanged: (value) { + _toggleFileSelection(hostFile.fileName, value ?? false); + }, + ), + onTap: () { + _toggleFileSelection(hostFile.fileName, !isSelected); + }, + contentPadding: EdgeInsets.zero, + ); + }, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widget/dialog/test_dialog.dart b/lib/widget/dialog/test_dialog.dart index 903d0db..755194a 100644 --- a/lib/widget/dialog/test_dialog.dart +++ b/lib/widget/dialog/test_dialog.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; import 'package:hosts/model/host_file.dart'; Future testDialog(BuildContext context, HostsModel host) { diff --git a/lib/widget/error/error_empty.dart b/lib/widget/error/error_empty.dart index b26ab9f..36eb3f4 100644 --- a/lib/widget/error/error_empty.dart +++ b/lib/widget/error/error_empty.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; class ErrorEmpty extends StatelessWidget { const ErrorEmpty({super.key}); diff --git a/lib/widget/home_drawer.dart b/lib/widget/home_drawer.dart index e3c51db..314ad76 100644 --- a/lib/widget/home_drawer.dart +++ b/lib/widget/home_drawer.dart @@ -1,7 +1,7 @@ import "dart:io"; import "package:flutter/material.dart"; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; import "package:hosts/model/simple_host_file.dart"; import "package:hosts/util/file_manager.dart"; import "package:hosts/util/settings_manager.dart"; @@ -268,4 +268,4 @@ class _HomeDrawerState extends State { }, ); } -} +} \ No newline at end of file diff --git a/lib/widget/host_base_view.dart b/lib/widget/host_base_view.dart deleted file mode 100644 index bfbd2e9..0000000 --- a/lib/widget/host_base_view.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hosts/model/host_file.dart'; - -abstract class HostBaseView extends StatelessWidget { - final List hosts; - final List selectHosts; - final Function(int, HostsModel) onEdit; - final Function(int, HostsModel) onLink; - final Function(int, HostsModel) onChecked; - final Function(List) onDelete; - final Function(List) onToggleUse; - final Function(String) onLaunchUrl; - - const HostBaseView({ - super.key, - required this.hosts, - required this.selectHosts, - required this.onChecked, - required this.onEdit, - required this.onLink, - required this.onDelete, - required this.onToggleUse, - required this.onLaunchUrl, - }); -} diff --git a/lib/widget/host_list.dart b/lib/widget/host_list.dart deleted file mode 100644 index 64068aa..0000000 --- a/lib/widget/host_list.dart +++ /dev/null @@ -1,239 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:hosts/model/host_file.dart'; -import 'package:hosts/widget/dialog/copy_dialog.dart'; -import 'package:hosts/widget/dialog/test_dialog.dart'; -import 'package:hosts/widget/host_base_view.dart'; - -class HostList extends HostBaseView { - const HostList({ - super.key, - required super.hosts, - required super.selectHosts, - required super.onEdit, - required super.onLink, - required super.onChecked, - required super.onDelete, - required super.onToggleUse, - required super.onLaunchUrl, - }); - - @override - Widget build(BuildContext context) { - return ListView.builder( - itemCount: hosts.length, - itemBuilder: (context, index) { - final HostsModel it = hosts[index]; - return InkWell( - onTap: () => onEdit(index, it), - child: ListItem( - host: it, - onSwitchChanged: (value) { - it.isUse = value; - - final List updateUseHosts = [it]; - void updateHostStates(List hostNames, bool isUse) { - for (var tempHost - in hosts.where((item) => hostNames.contains(item.host))) { - tempHost.isUse = isUse; - updateUseHosts.add(tempHost); - } - } - - // 相同 - if (it.config["same"] != null) { - updateHostStates( - (it.config["same"] as List).cast(), value); - } - // 相反 - if (it.config["contrary"] != null) { - updateHostStates( - (it.config["contrary"] as List).cast(), - !value); - } - - onToggleUse(updateUseHosts); - }, - onCheckChanged: (value) => onChecked(index, it), - trailing: buildMoreButton(context, index, it), - isChecked: selectHosts.contains(it), - ), - ); - }, - ); - } - - Widget buildMoreButton(BuildContext context, int index, HostsModel host) { - return PopupMenuButton( - onSelected: (value) async { - switch (value) { - case 1: - onLink(index, host); - break; - case 2: - testDialog(context, host); - break; - case 3: - copyDialog(context, hosts, index); - break; - case 4: - onDelete([host]); - break; - } - }, - itemBuilder: (BuildContext context) { - List> list = [ - { - "icon": Icons.link, - "text": AppLocalizations.of(context)!.link, - "value": 1 - }, - { - "icon": Icons.sensors, - "text": AppLocalizations.of(context)!.test, - "value": 2 - }, - { - "icon": Icons.copy, - "text": AppLocalizations.of(context)!.copy, - "value": 3 - }, - { - "icon": Icons.delete_outline, - "text": AppLocalizations.of(context)!.delete, - "value": 4 - }, - ]; - - return list - .where((item) => !(item["value"] == 2 && kIsWeb)) - .map((item) { - return PopupMenuItem( - value: int.parse(item["value"].toString()), - child: Row( - children: [ - Icon(item["icon"]! as IconData), - const SizedBox(width: 8), - Text(item["text"]!.toString()), - ], - ), - ); - }).toList(); - }, - ); - } -} - -class ListItem extends StatelessWidget { - final bool isChecked; - final HostsModel host; - final ValueChanged onSwitchChanged; - final ValueChanged onCheckChanged; - final Widget trailing; - - const ListItem( - {super.key, - required this.host, - required this.onSwitchChanged, - required this.onCheckChanged, - required this.isChecked, - required this.trailing}); - - @override - Widget build(BuildContext context) { - bool isLink = false; - if (host.config.isNotEmpty) { - isLink = host.config["same"] != null && host.config["contrary"] != null; - } - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 9), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Checkbox( - value: isChecked, - onChanged: onCheckChanged, - ), - const SizedBox(width: 16), - Switch( - value: host.isUse, - onChanged: onSwitchChanged, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (host.description.isNotEmpty) - Text( - host.description, - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: 4.0), - Text.rich(TextSpan( - children: [ - if (isLink) - WidgetSpan( - child: Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - Icons.link, - color: Theme.of(context).colorScheme.primary, - size: 18, - ), - )), - TextSpan( - text: host.host, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold), - ) - ], - )), - const SizedBox(height: 4.0), - Text.rich(TextSpan( - children: _buildTextSpans(host.hosts, context), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.6), - fontWeight: FontWeight.bold))), - ], - ), - ), - const SizedBox(width: 16), - trailing, - ], - ), - ); - } - - List _buildTextSpans(List hosts, BuildContext context) { - List textSpans = []; - - for (int i = 0; i < hosts.length; i++) { - textSpans.add(TextSpan( - text: hosts[i], - recognizer: TapGestureRecognizer() - ..onTap = () { - // onLaunchUrl(hosts[i]); - }, - )); - - if (i < hosts.length - 1) { - textSpans.add(TextSpan( - text: ' - ', - style: TextStyle( - color: Theme.of(context).colorScheme.inverseSurface, - fontWeight: FontWeight.w900))); - } - } - - return textSpans; - } -} diff --git a/lib/widget/host_table.dart b/lib/widget/host_table.dart deleted file mode 100644 index 45399ef..0000000 --- a/lib/widget/host_table.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:hosts/model/host_file.dart'; -import 'package:hosts/widget/dialog/copy_dialog.dart'; -import 'package:hosts/widget/dialog/test_dialog.dart'; -import 'package:hosts/widget/host_base_view.dart'; - -class HostTable extends HostBaseView { - const HostTable({ - super.key, - required super.hosts, - required super.selectHosts, - required super.onEdit, - required super.onLink, - required super.onChecked, - required super.onDelete, - required super.onToggleUse, - required super.onLaunchUrl, - }); - - List tableBody(BuildContext context) { - return hosts.asMap().entries.map((entry) { - final int index = entry.key; - final it = entry.value; - - bool isLink = false; - if (it.config.isNotEmpty) { - isLink = it.config["same"] != null && it.config["contrary"] != null; - } - return TableRow(children: [ - Checkbox( - value: selectHosts.contains(it), - onChanged: (bool? newValue) => onChecked(index, it), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () => onLaunchUrl(it.host), - child: Text.rich(TextSpan( - children: [ - if (isLink) - WidgetSpan( - child: Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - Icons.link, - color: Theme.of(context).colorScheme.primary, - size: 18, - ), - )), - TextSpan( - text: it.host, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ) - ], - )), - ), - ), - Align( - alignment: Alignment.centerLeft, - child: Switch( - value: it.isUse, - onChanged: (value) { - it.isUse = value; - - final List updateUseHosts = [it]; - void updateHostStates(List hostNames, bool isUse) { - for (var tempHost - in hosts.where((item) => hostNames.contains(item.host))) { - tempHost.isUse = isUse; - updateUseHosts.add(tempHost); - } - } - - // 相同 - if (it.config["same"] != null) { - updateHostStates( - (it.config["same"] as List).cast(), value); - } - // 相反 - if (it.config["contrary"] != null) { - updateHostStates( - (it.config["contrary"] as List).cast(), - !value); - } - - onToggleUse(updateUseHosts); - }, - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text.rich(TextSpan( - children: _buildTextSpans(it.hosts, context), - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold))), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: SelectableText(it.description), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - onPressed: () => onEdit(index, it), - icon: const Icon(Icons.edit), - ), - const SizedBox(width: 8), - IconButton( - onPressed: () => onDelete([it]), - icon: const Icon(Icons.delete_outline), - ), - const SizedBox(width: 8), - buildMoreButton(context, index, it), - ], - ), - ) - ]); - }).toList(); - } - - List _buildTextSpans(List hosts, BuildContext context) { - List textSpans = []; - - for (int i = 0; i < hosts.length; i++) { - textSpans.add(TextSpan( - text: hosts[i], - recognizer: TapGestureRecognizer() - ..onTap = () { - onLaunchUrl(hosts[i]); - }, - )); - - if (i < hosts.length - 1) { - textSpans.add(TextSpan( - text: ' - ', - style: TextStyle( - color: Theme.of(context).colorScheme.inverseSurface, - fontWeight: FontWeight.w900))); - } - } - - return textSpans; - } - - Widget buildMoreButton(BuildContext context, int index, HostsModel host) { - return PopupMenuButton( - onSelected: (value) async { - switch (value) { - case 1: - onLink(index, host); - break; - case 2: - testDialog(context, host); - break; - case 3: - copyDialog(context, hosts, index); - break; - } - }, - itemBuilder: (BuildContext context) { - List> list = [ - { - "icon": Icons.link, - "text": AppLocalizations.of(context)!.link, - "value": 1 - }, - { - "icon": Icons.sensors, - "text": AppLocalizations.of(context)!.test, - "value": 2 - }, - { - "icon": Icons.copy, - "text": AppLocalizations.of(context)!.copy, - "value": 3 - }, - ]; - - return list - .where((item) => !(item["value"] == 2 && kIsWeb)) - .map((item) { - return PopupMenuItem( - value: int.parse(item["value"].toString()), - child: Row( - children: [ - Icon(item["icon"]! as IconData), - const SizedBox(width: 8), - Text(item["text"]!.toString()), - ], - ), - ); - }).toList(); - }, - ); - } - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: Table( - columnWidths: const { - 0: FixedColumnWidth(50), - 2: FixedColumnWidth(100), - 3: FlexColumnWidth(2), - 5: FixedColumnWidth(180), - }, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: tableBody(context), - ), - ); - } -} diff --git a/lib/widget/host_text_editing_controller.dart b/lib/widget/host_text_editing_controller.dart index 2f52a1a..bda8340 100644 --- a/lib/widget/host_text_editing_controller.dart +++ b/lib/widget/host_text_editing_controller.dart @@ -15,6 +15,21 @@ class HostTextEditingController extends TextEditingController { TextStyle? style, bool? withComposing, }) { + // Ensure selection is within bounds before building text span + final int textLength = text.length; + if (selection.baseOffset > textLength || selection.extentOffset > textLength) { + final TextSelection boundedSelection = selection.copyWith( + baseOffset: min(selection.baseOffset, textLength), + extentOffset: min(selection.extentOffset, textLength), + ); + // Update selection without triggering infinite recursion + WidgetsBinding.instance.addPostFrameCallback((_) { + if (selection != boundedSelection) { + selection = boundedSelection; + } + }); + } + lines.clear(); lines.addAll(text.split("\n")); @@ -177,7 +192,15 @@ class HostTextEditingController extends TextEditingController { set value(TextEditingValue newValue) { lines.clear(); lines.addAll(newValue.text.split("\n")); - super.value = newValue; + + // Ensure selection is within bounds + final int textLength = newValue.text.length; + final TextSelection boundedSelection = newValue.selection.copyWith( + baseOffset: min(newValue.selection.baseOffset, textLength), + extentOffset: min(newValue.selection.extentOffset, textLength), + ); + + super.value = newValue.copyWith(selection: boundedSelection); } int countNewlines(String text) { diff --git a/lib/widget/hosts_diff_viewer.dart b/lib/widget/hosts_diff_viewer.dart new file mode 100644 index 0000000..aea5743 --- /dev/null +++ b/lib/widget/hosts_diff_viewer.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:diff_match_patch/diff_match_patch.dart'; + +class HostsDiffViewer extends StatelessWidget { + final String oldContent; + final String newContent; + final String? oldLabel; + final String? newLabel; + + const HostsDiffViewer({ + Key? key, + required this.oldContent, + required this.newContent, + this.oldLabel, + this.newLabel, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dmp = DiffMatchPatch(); + final diffs = dmp.diff(oldContent, newContent); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 标题栏 + if (oldLabel != null || newLabel != null) + Container( + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant, + borderRadius: BorderRadius.vertical(top: Radius.circular(8)), + ), + child: Row( + children: [ + if (oldLabel != null) ...[ + Icon(Icons.remove, color: theme.colorScheme.error, size: 16), + SizedBox(width: 4), + Expanded(child: Text(oldLabel!, style: theme.textTheme.labelMedium)), + ], + if (oldLabel != null && newLabel != null) + Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.compare_arrows, size: 16), + ), + if (newLabel != null) ...[ + Icon(Icons.add, color: theme.colorScheme.primary, size: 16), + SizedBox(width: 4), + Expanded(child: Text(newLabel!, style: theme.textTheme.labelMedium)), + ], + ], + ), + ), + + // 差异内容 + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + padding: EdgeInsets.all(16), + child: RichText( + text: TextSpan( + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + height: 1.4, + ), + children: diffs.map((diff) => _buildDiffSpan(diff, theme)).toList(), + ), + ), + ), + ), + ), + ], + ); + } + + TextSpan _buildDiffSpan(Diff diff, ThemeData theme) { + switch (diff.operation) { + case DIFF_INSERT: + return TextSpan( + text: diff.text, + style: TextStyle( + backgroundColor: theme.colorScheme.primaryContainer.withOpacity(0.3), + color: theme.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ); + case DIFF_DELETE: + return TextSpan( + text: diff.text, + style: TextStyle( + backgroundColor: theme.colorScheme.errorContainer.withOpacity(0.3), + color: theme.colorScheme.error, + decoration: TextDecoration.lineThrough, + fontWeight: FontWeight.w500, + ), + ); + default: + return TextSpan( + text: diff.text, + style: TextStyle(color: theme.colorScheme.onSurface), + ); + } + } +} \ No newline at end of file diff --git a/lib/widget/row_line_widget.dart b/lib/widget/row_line_widget.dart index bf18a54..173a09e 100644 --- a/lib/widget/row_line_widget.dart +++ b/lib/widget/row_line_widget.dart @@ -25,12 +25,6 @@ class RowLineWidget extends StatelessWidget { } Widget buildRowLine() { - double textFieldContainerWidth = 0; - final RenderBox? renderBox = - textFieldContainerKey.currentContext?.findRenderObject() as RenderBox?; - if (renderBox != null) { - textFieldContainerWidth = renderBox.size.width; - } final TextStyle? titleMedium = Theme.of(context).textTheme.titleMedium; final TextSelection textSelection = textEditingController.selection; final List lines = textEditingController.text.split('\n'); @@ -53,42 +47,45 @@ class RowLineWidget extends StatelessWidget { return Container( width: containerWidth, padding: const EdgeInsets.only(top: 4), - child: SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), - controller: scrollController, - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.end, - children: List.generate(lines.length, (index) { - final String line = lines[index]; - - final TextPainter textPainter = TextPainter( - text: TextSpan( - text: line, - style: titleMedium, - ), - textDirection: TextDirection.ltr, - )..layout(); - - final double width = textPainter.width + fontSize; - - return buildIndexedLineContainer( - containerWidth, - selectedLine.contains(index), - "${index + 1}", - line, - () { - if (selectedLine.contains(index)) { - textEditingController.updateUseStatus(textSelection); - return; - } - final int length = - lines.sublist(0, index + 1).join("\n").length; - textEditingController.updateUseStatus( - TextSelection(baseOffset: length, extentOffset: length)); - }, - ); - }), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + controller: scrollController, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(lines.length, (index) { + final String line = lines[index]; + + final TextPainter textPainter = TextPainter( + text: TextSpan( + text: line, + style: titleMedium, + ), + textDirection: TextDirection.ltr, + )..layout(); + + final double width = textPainter.width + fontSize; + + return buildIndexedLineContainer( + containerWidth, + selectedLine.contains(index), + "${index + 1}", + line, + () { + if (selectedLine.contains(index)) { + textEditingController.updateUseStatus(textSelection); + return; + } + final int length = + lines.sublist(0, index + 1).join("\n").length; + textEditingController.updateUseStatus( + TextSelection(baseOffset: length, extentOffset: length)); + }, + ); + }), + ), ), ), ); diff --git a/lib/widget/snakbar.dart b/lib/widget/snakbar.dart index f112871..6f2f07b 100644 --- a/lib/widget/snakbar.dart +++ b/lib/widget/snakbar.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; void deleteMultiple( BuildContext context, List array, VoidCallback onRemove) { if (array.isEmpty) return; + ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(array.length == 1 ? AppLocalizations.of(context)!.remove_single_tip(array.first) diff --git a/lib/widget/text_field/search_dialog.dart b/lib/widget/text_field/search_dialog.dart deleted file mode 100644 index eda5145..0000000 --- a/lib/widget/text_field/search_dialog.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter/material.dart'; - -class SearchDialog extends StatefulWidget { - const SearchDialog({super.key}); - - @override - _SearchDialogState createState() => _SearchDialogState(); -} - -class _SearchDialogState extends State { - final TextEditingController _searchTextEditingController = - TextEditingController(); - final TextEditingController _replaceTextEditingController = - TextEditingController(); - bool regexChecked = false; - bool caseSensitiveChecked = false; - bool wholeWordChecked = false; - - @override - void initState() { - _searchTextEditingController.addListener(() {}); - - _replaceTextEditingController.addListener(() {}); - super.initState(); - } - - @override - void dispose() { - super.dispose(); - _searchTextEditingController.dispose(); - _replaceTextEditingController.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - ), - child: Row( - children: [ - Expanded( - child: Column( - children: [ - TextFormField( - controller: _searchTextEditingController, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.search), - suffix: Text( - "1/1", - style: Theme.of(context).textTheme.titleMedium, - ), - label: const Text("查询"), - ), - ), - TextFormField( - controller: _replaceTextEditingController, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.find_replace), - label: Text("替换"), - ), - ), - const SizedBox(height: 8), - Row( - children: [ - InkWell( - borderRadius: BorderRadius.circular(8.0), - onTap: () => setState(() { - regexChecked = !regexChecked; - }), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IgnorePointer( - child: Checkbox( - value: regexChecked, - onChanged: - (bool? value) {}, // Keep Checkbox disabled - ), - ), - const Text("正则表达式"), - const SizedBox(width: 8), - ], - ), - ), - InkWell( - borderRadius: BorderRadius.circular(8.0), - onTap: () => setState(() { - caseSensitiveChecked = !caseSensitiveChecked; - }), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IgnorePointer( - child: Checkbox( - value: caseSensitiveChecked, - onChanged: - (bool? value) {}, // Keep Checkbox disabled - ), - ), - const Text("区分大小写"), - const SizedBox(width: 8), - ], - ), - ), - InkWell( - borderRadius: BorderRadius.circular(8.0), - onTap: () => setState(() { - wholeWordChecked = !wholeWordChecked; - }), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IgnorePointer( - child: Checkbox( - value: wholeWordChecked, - onChanged: - (bool? value) {}, // Keep Checkbox disabled - ), - ), - const Text("只匹配整个单词"), - const SizedBox(width: 8), - ], - ), - ), - ], - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Row( - children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.arrow_upward), - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.arrow_downward)), - IconButton( - onPressed: () {}, icon: const Icon(Icons.find_replace)), - IconButton( - onPressed: () {}, icon: const Icon(Icons.settings)), - ], - ), - const Divider(), - Row( - children: [ - TextButton(onPressed: () {}, child: const Text("替换")), - TextButton(onPressed: () {}, child: const Text("全部替换")), - ], - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/widget/text_field/search_text_field.dart b/lib/widget/text_field/search_text_field.dart index 47e8aea..594d270 100644 --- a/lib/widget/text_field/search_text_field.dart +++ b/lib/widget/text_field/search_text_field.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hosts/l10n/app_localizations.dart'; class SearchTextField extends StatefulWidget { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/linux/hosts.desktop b/linux/hosts.desktop index b332743..b623358 100644 --- a/linux/hosts.desktop +++ b/linux/hosts.desktop @@ -2,9 +2,25 @@ Version=1.0 Type=Application Name=Hosts Editor -Comment=Linux MacOS Windows Hosts File Editor +GenericName=Hosts File Editor +Comment=A cross-platform hosts file editor built with Flutter +Keywords=hosts;editor;network;system;dns; Exec=hosts %U -Icon=icon.png +Icon=hosts-editor Terminal=false -Categories=Utility;Application; -StartupNotify=true \ No newline at end of file +Categories=System;Network;Utility; +StartupNotify=true +MimeType=text/plain; +X-Desktop-File-Install-Version=0.26 + +[Desktop Action SimpleMode] +Name=Simple Mode +Exec=hosts --mode simple +Icon=hosts-editor + +[Desktop Action OpenHostsFile] +Name=Open Hosts File +Exec=hosts --open /etc/hosts +Icon=hosts-editor + +Actions=SimpleMode;OpenHostsFile; \ No newline at end of file diff --git a/linux/packaging/appimage/make_config.yaml b/linux/packaging/appimage/make_config.yaml new file mode 100644 index 0000000..a26d2c9 --- /dev/null +++ b/linux/packaging/appimage/make_config.yaml @@ -0,0 +1,41 @@ +display_name: Hosts Editor + +icon: linux/icon.png + +keywords: + - Hosts + - Editor + - Network + - System + - DNS + +generic_name: Hosts File Editor + +actions: + - name: Open Hosts File + label: open-hosts + arguments: + - --open + - /etc/hosts + - name: Simple Mode + label: simple-mode + arguments: + - --mode + - simple + +categories: + - System + - Network + - Utility + +startup_notify: true + +# 您可以指定要与您的应用捆绑的共享库 +# +# fastforge 会自动检测您的应用所依赖的共享库,但您也可以在此处手动指定它们。 +# +# 以下示例展示了如何将 libcurl 库与您的应用捆绑在一起 +# +# include: +# - libcurl.so.4 +include: [] \ No newline at end of file diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml new file mode 100644 index 0000000..994c258 --- /dev/null +++ b/linux/packaging/deb/make_config.yaml @@ -0,0 +1,32 @@ +display_name: Hosts Editor +package_name: hosts-editor +maintainer: + name: Webb + email: 822028533@qq.com +priority: optional +section: utils +installed_size: 50000 +essential: false +icon: linux/icon.png + +postinstall_scripts: + - echo "Hosts Editor installed successfully" + - echo "You can launch it from the applications menu or run 'hosts-editor' in terminal" +postuninstall_scripts: + - echo "Hosts Editor has been uninstalled" + +keywords: + - Hosts + - Editor + - Network + - System + - DNS + +generic_name: Hosts File Editor + +categories: + - System + - Network + - Utility + +startup_notify: true \ No newline at end of file diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml new file mode 100644 index 0000000..38792b0 --- /dev/null +++ b/linux/packaging/rpm/make_config.yaml @@ -0,0 +1,26 @@ +icon: linux/icon.png +summary: A cross-platform hosts file editor built with Flutter +group: Applications/System +vendor: Webb +packager: Webb +packagerEmail: 822028533@qq.com +license: MIT +url: https://github.com/webb-l/hosts + +display_name: Hosts Editor + +keywords: + - Hosts + - Editor + - Network + - System + - DNS + +generic_name: Hosts File Editor + +categories: + - System + - Network + - Utility + +startup_notify: true \ No newline at end of file diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b8e2b22..e3d4070 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,18 @@ import FlutterMacOS import Foundation +import file_picker +import network_info_plus +import package_info_plus import path_provider_foundation import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index f37c5a1..0118e0c 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 3F55E16061778D6F20835434 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E889EC860D2F7BA9D9722B31 /* Pods_Runner.framework */; }; + F19DFA0754E4847621807A92 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 297457D0F304F0F853517024 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 286CDE1F1D210D69780895EA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 297457D0F304F0F853517024 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* hosts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "hosts.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* hosts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = hosts.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +80,14 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 49ECC88EAD1B645FFA58948D /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 4E7B105E9F539AFB3D6B7B58 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8B41A85B58DED4695D296251 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + CAE52630D3CE470C54F4E04E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + DFED14FADB1C32FF7E538D04 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + E889EC860D2F7BA9D9722B31 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F19DFA0754E4847621807A92 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3F55E16061778D6F20835434 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 707625401C7FAFA8334CAAED /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 707625401C7FAFA8334CAAED /* Pods */ = { + isa = PBXGroup; + children = ( + 8B41A85B58DED4695D296251 /* Pods-Runner.debug.xcconfig */, + CAE52630D3CE470C54F4E04E /* Pods-Runner.release.xcconfig */, + 286CDE1F1D210D69780895EA /* Pods-Runner.profile.xcconfig */, + 49ECC88EAD1B645FFA58948D /* Pods-RunnerTests.debug.xcconfig */, + 4E7B105E9F539AFB3D6B7B58 /* Pods-RunnerTests.release.xcconfig */, + DFED14FADB1C32FF7E538D04 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + E889EC860D2F7BA9D9722B31 /* Pods_Runner.framework */, + 297457D0F304F0F853517024 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + E87E959E47DB7B36A0BEB3D4 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 787AADE9E303C852C45269B9 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + BE46BD8567808E3E601E4A37 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -329,6 +361,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 787AADE9E303C852C45269B9 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + BE46BD8567808E3E601E4A37 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + E87E959E47DB7B36A0BEB3D4 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 49ECC88EAD1B645FFA58948D /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 4E7B105E9F539AFB3D6B7B58 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DFED14FADB1C32FF7E538D04 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index dbc8bee..8f7c4cc 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 8e02df2..b3c1761 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -6,4 +6,8 @@ class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f..96d3fee 100644 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" + "info": { + "version": 1, + "author": "xcode" }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d..3c5f1c0 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eb..c626d3a 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa..6f87f7b 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb5722..580b78e 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318..4ddbb18 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e7..5aec90a 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632c..2fa35ba 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..dc3d49d 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -3,10 +3,18 @@ com.apple.security.app-sandbox - + com.apple.security.cs.allow-jit com.apple.security.network.server + com.apple.security.files.user-selected.read-write + + com.apple.security.files.all + + com.apple.security.temporary-exception.files.absolute-path.read-write + + /etc/hosts + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa..04c5c6e 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -20,6 +20,8 @@ $(FLUTTER_BUILD_NAME) CFBundleVersion $(FLUTTER_BUILD_NUMBER) + LSApplicationCategoryType + public.app-category.utilities LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright @@ -28,5 +30,11 @@ MainMenu NSPrincipalClass NSApplication + NSAppleEventsUsageDescription + 此应用需要管理员权限以修改系统hosts文件 + NSAppleMusicUsageDescription + 此应用需要访问系统资源 + NSSystemAdministrationUsageDescription + 此应用需要管理员权限修改系统hosts文件 diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb..da5d8ba 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,15 +1,101 @@ import Cocoa import FlutterMacOS +import Security class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) + private var hostsChannel: FlutterMethodChannel? - RegisterGeneratedPlugins(registry: flutterViewController) + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) - super.awakeFromNib() - } + // 设置方法通道 + hostsChannel = FlutterMethodChannel( + name: "top.webb_l.hosts/system", + binaryMessenger: flutterViewController.engine.binaryMessenger) + + hostsChannel?.setMethodCallHandler { [weak self] (call, result) in + print("收到方法调用: \(call.method)") + switch call.method { + case "modifyHostsFile": + if let args = call.arguments as? [String: Any], + let hostsContent = args["content"] as? String { + self?.modifyHostsFile(content: hostsContent, completion: { success, errorMessage in + if success { + result(true) + } else { + result(FlutterError(code: "HOSTS_MODIFY_ERROR", + message: errorMessage, + details: nil)) + } + }) + } else { + result(FlutterError(code: "INVALID_ARGUMENTS", + message: "缺少内容参数", + details: nil)) + } + default: + result(FlutterMethodNotImplemented) + } + } + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } + + func modifyHostsFile(content: String, completion: @escaping (Bool, String?) -> Void) { + print("尝试修改 Hosts 文件...") + + guard !content.isEmpty else { + print("内容不能为空") + completion(false, "Hosts 文件内容不能为空") + return + } + + // 1. 将新内容写入临时文件 + let tempFilePath = NSTemporaryDirectory().appending("top.webb_l.hosts.temp") + do { + try content.write(toFile: tempFilePath, atomically: true, encoding: .utf8) + } catch { + print("写入临时文件失败: \(error)") + completion(false, "写入临时文件失败: \(error.localizedDescription)") + return + } + + // 2. 使用osascript尝试以管理员身份运行命令 + let scriptProcess = Process() + scriptProcess.launchPath = "/usr/bin/osascript" + scriptProcess.arguments = [ + "-e", "do shell script \"/bin/cp \(tempFilePath) /etc/hosts\" with administrator privileges" + ] + + let pipe = Pipe() + scriptProcess.standardError = pipe + scriptProcess.standardOutput = pipe + + do { + try scriptProcess.run() + scriptProcess.waitUntilExit() + + if scriptProcess.terminationStatus == 0 { + print("成功修改hosts文件") + try? FileManager.default.removeItem(atPath: tempFilePath) + completion(true, nil) + } else { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let errorOutput = String(data: data, encoding: .utf8) ?? "未知错误" + print("执行失败: \(errorOutput)") + completion(false, "操作失败: \(errorOutput)") + } + } catch { + print("启动进程失败: \(error)") + completion(false, "无法启动进程: \(error.localizedDescription)") + } + + // 清理临时文件 + try? FileManager.default.removeItem(atPath: tempFilePath) + } } diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..1a0843a 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -3,6 +3,14 @@ com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + com.apple.security.files.all + + com.apple.security.temporary-exception.files.absolute-path.read-write + + /etc/hosts + diff --git a/macos/packaging/dmg/background.png b/macos/packaging/dmg/background.png new file mode 100644 index 0000000..93243b7 Binary files /dev/null and b/macos/packaging/dmg/background.png differ diff --git a/macos/packaging/dmg/background.svg b/macos/packaging/dmg/background.svg new file mode 100644 index 0000000..243dc39 --- /dev/null +++ b/macos/packaging/dmg/background.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + Hosts Editor + + + + + A cross-platform hosts file editor + + + + + Drag Hosts Editor to Applications folder to install + + + + + + + + + + + + + + + Hosts Editor + + + + + + Applications + + + + + 1. Drag the app icon to Applications folder + + + 2. Launch from Applications or Spotlight + + \ No newline at end of file diff --git a/macos/packaging/dmg/make_config.yaml b/macos/packaging/dmg/make_config.yaml new file mode 100644 index 0000000..a9cbc94 --- /dev/null +++ b/macos/packaging/dmg/make_config.yaml @@ -0,0 +1,19 @@ +title: Hosts Editor +# icon: assets/icon/logo.png # 暂时移除图标避免格式问题 +background: background.png +window: + size: + width: 640 + height: 480 + position: + x: 200 + y: 120 +contents: + - x: 160 + y: 240 + type: file + path: hosts.app + - x: 480 + y: 240 + type: link + path: '/Applications' \ No newline at end of file diff --git a/macos/packaging/pkg/make_config.yaml b/macos/packaging/pkg/make_config.yaml new file mode 100644 index 0000000..93a3d55 --- /dev/null +++ b/macos/packaging/pkg/make_config.yaml @@ -0,0 +1,2 @@ +install-path: /Applications +# sign-identity: "-" \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index bd8c867..9ac63f9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _discoveryapis_commons: + dependency: transitive + description: + name: _discoveryapis_commons + sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498" + url: "https://pub.dev" + source: hosted + version: "1.0.7" ansicolor: dependency: transitive description: @@ -10,7 +18,7 @@ packages: source: hosted version: "2.0.3" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d @@ -21,50 +29,90 @@ packages: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "9.0.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" + charset: + dependency: transitive + description: + name: charset + sha256: "27802032a581e01ac565904ece8c8962564b1070690794f0072f6865958ce8b9" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" + console: + dependency: transitive + description: + name: console + sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a + url: "https://pub.dev" + source: hosted + version: "4.1.0" cross_file: dependency: transitive description: @@ -77,18 +125,18 @@ packages: dependency: transitive description: name: crypto - sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" csslib: dependency: transitive description: name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -97,51 +145,171 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + diff_match_patch: + dependency: "direct main" + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + dio: + dependency: transitive + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" + fastforge: + dependency: "direct dev" + description: + name: fastforge + sha256: eb8c80b3c071cafa8df48f8fed53381df2bd26587e33ed73cfaf73c5517a3e32 + url: "https://pub.dev" + source: hosted + version: "0.6.2" ffi: dependency: transitive description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" file_picker: dependency: "direct main" description: name: file_picker - sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" + sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964" url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "10.1.9" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_app_builder: + dependency: transitive + description: + name: flutter_app_builder + sha256: "75b9be9ba8b67d445a117368f7e603228e8a434136c71a3051307fc727be74f4" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + flutter_app_packager: + dependency: transitive + description: + name: flutter_app_packager + sha256: "2061d4ea68caa15b92b42f100e9bfffeef966eec089d04d934bfb0572258f4e2" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + flutter_app_publisher: + dependency: transitive + description: + name: flutter_app_publisher + sha256: "16d7e93a6722fafb6ee10b28023d8df92d5c8c8abe66b7bbe741e7fe64952b03" + url: "https://pub.dev" + source: hosted + version: "0.6.2" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 + url: "https://pub.dev" + source: hosted + version: "9.1.1" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_temp_fork: + dependency: "direct main" + description: + name: flutter_keyboard_visibility_temp_fork + sha256: e3d02900640fbc1129245540db16944a0898b8be81694f4bf04b6c985bed9048 + url: "https://pub.dev" + source: hosted + version: "0.1.5" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -151,18 +319,26 @@ packages: dependency: "direct dev" description: name: flutter_native_splash - sha256: aa06fec78de2190f3db4319dd60fdc8d12b2626e93ef9828633928c2dcaea840 + sha256: "7062602e0dbd29141fb8eb19220b5871ca650be5197ab9c1f193a28b17537bc7" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.4" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "9ee02950848f61c4129af3d6ec84a1cfc0e47931abc746b03e7a3bc3e8ff6eda" + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" url: "https://pub.dev" source: hosted - version: "2.0.22" + version: "0.7.0" flutter_test: dependency: "direct dev" description: flutter @@ -173,46 +349,126 @@ packages: description: flutter source: sdk version: "0.0.0" + get_it: + dependency: transitive + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + googleapis: + dependency: transitive + description: + name: googleapis + sha256: "864f222aed3f2ff00b816c675edf00a39e2aaf373d728d8abec30b37bee1a81c" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + googleapis_auth: + dependency: transitive + description: + name: googleapis_auth + sha256: befd71383a955535060acde8792e7efc11d2fccd03dd1d3ec434e85b68775938 + url: "https://pub.dev" + source: hosted + version: "1.6.0" html: dependency: transitive description: name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.4" + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" image: dependency: transitive description: name: image - sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.3.0" intl: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "4.9.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -225,18 +481,34 @@ packages: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.0.0" + liquid_engine: + dependency: transitive + description: + name: liquid_engine + sha256: "41ae12d5a72451c3efb8d4e7b901cdf0537917597bc7e7376e9b0a237f92df29" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -249,42 +521,122 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + msix: + dependency: transitive + description: + name: msix + sha256: edde648a8133bf301883c869d19d127049683037c65ff64173ba526ac7a8af2f + url: "https://pub.dev" + source: hosted + version: "3.16.9" + mustache_template: + dependency: transitive + description: + name: mustache_template + sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + network_info_plus: + dependency: "direct main" + description: + name: network_info_plus + sha256: f926b2ba86aa0086a0dfbb9e5072089bc213d854135c1712f1d29fc89ba3c877 url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "6.1.4" + network_info_plus_platform_interface: + dependency: transitive + description: + name: network_info_plus_platform_interface + sha256: "7e7496a8a9d8136859b8881affc613c4a21304afeb6c324bcefc4bd0aff6b94b" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + parse_app_package: + dependency: transitive + description: + name: parse_app_package + sha256: "8ff00a930da628d5270f58f2befbbf10185ed7959b093195985bf4132e84bc8a" + url: "https://pub.dev" + source: hosted + version: "0.6.0" path: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_provider: dependency: "direct main" description: name: path_provider - sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.10" + version: "2.2.17" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -313,18 +665,26 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.1.0" platform: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" + plist_parser: + dependency: transitive + description: + name: plist_parser + sha256: e2a6f9abfa0c45c0253656b7360abb0dfb84af9937bace74605b93d2aad2bf0c + url: "https://pub.dev" + source: hosted + version: "0.0.11" plugin_platform_interface: dependency: transitive description: @@ -333,30 +693,86 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + provider: + dependency: transitive + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + qiniu_sdk_base: + dependency: transitive + description: + name: qiniu_sdk_base + sha256: "2506c6372512f81cfbddf162ea6da1ad7b1c6521dee1d10e9da6847c92e13349" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.10" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: c4b35f6cb8f63c147312c054ce7c2254c8066745125264f0c88739c417fc9d9f + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.5.4" shared_preferences_linux: dependency: transitive description: @@ -377,10 +793,10 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" shared_preferences_windows: dependency: transitive description: @@ -389,67 +805,123 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: "direct main" + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + shell_executor: + dependency: transitive + description: + name: shell_executor + sha256: "6f41c85bffc7e839401bebc8c75cb189896ff038072060d3b0ec538949b615fc" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + shell_uikit: + dependency: transitive + description: + name: shell_uikit + sha256: "8d5bb6bb0b220d47ef11adbe001cb31944b8eb8fe6183687258d36c9320d6c75" + url: "https://pub.dev" + source: hosted + version: "0.3.0" sky_engine: 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://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: "0e1e1f50edca35bf1a36c75ebd9c4bef722d3ff9998dac9cee3bf11745639d6a" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "29.2.10" + syncfusion_flutter_datagrid: + dependency: "direct main" + description: + name: syncfusion_flutter_datagrid + sha256: "5006834400e1c9d5a2e41c26a168bac31f521b09cd33f6b4bae1eb095a3d614a" + url: "https://pub.dev" + source: hosted + version: "29.2.10" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.4" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" + unified_distributor: + dependency: transitive + description: + name: unified_distributor + sha256: "65732c3c9650de405646df52317d0ed24adc556091333ebfd8684ad72060a46c" + url: "https://pub.dev" + source: hosted + version: "0.2.2" universal_io: dependency: transitive description: @@ -458,6 +930,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + url: "https://pub.dev" + source: hosted + version: "6.3.16" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + url: "https://pub.dev" + source: hosted + version: "3.0.7" vector_math: dependency: transitive description: @@ -470,26 +1014,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "15.0.0" web: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" win32: dependency: transitive description: name: win32 - sha256: "4d45dc9069dba4619dc0ebd93c7cec5e66d8482cb625a370ac806dcc8165f2ec" + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" url: "https://pub.dev" source: hosted - version: "5.5.5" + version: "5.13.0" xdg_directories: dependency: transitive description: @@ -510,10 +1054,10 @@ packages: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.5.0 <4.0.0" - flutter: ">=3.22.0" + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 14dfba4..1844076 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,59 +1,70 @@ name: hosts description: "Hosts Editor" -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.5.0 +publish_to: 'none' # 防止意外发布到pub.dev +version: 1.8.0 environment: sdk: ^3.5.0 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. +# 依赖项管理提示: 使用`flutter pub upgrade --major-versions`自动升级 dependencies: + # Flutter SDK flutter: sdk: flutter + flutter_localizations: + sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + # UI组件 cupertino_icons: ^1.0.8 - path_provider: ^2.1.4 + syncfusion_flutter_datagrid: ^29.2.7+1 + flutter_staggered_grid_view: ^0.7.0 + + # 状态管理 + flutter_bloc: ^9.1.1 + bloc: ^9.0.0 + equatable: ^2.0.5 + + # 本地存储 shared_preferences: ^2.3.2 + + # 文件操作 + path_provider: ^2.1.4 path: ^1.9.0 - flutter_localizations: - sdk: flutter + file_picker: ^10.1.9 + archive: ^3.6.1 + + # 国际化 intl: any - file_picker: ^8.1.2 + + # 服务器 + shelf: ^1.4.2 + shelf_router: ^1.1.4 + + # 网络信息 + network_info_plus: ^6.1.4 + + # 二维码生成 + qr_flutter: ^4.1.0 + + # URL启动器 + url_launcher: ^6.3.1 + + # 应用信息 + package_info_plus: ^8.1.0 + + # 文本差异对比 + diff_match_patch: ^0.4.1 + + # 键盘可见性检测 + flutter_keyboard_visibility_temp_fork: ^0.1.5 dev_dependencies: flutter_test: sdk: flutter - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # 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_lints: ^6.0.0 flutter_native_splash: ^2.4.1 + fastforge: ^0.6.2 flutter_native_splash: color: "#000000" @@ -75,29 +86,3 @@ flutter: assets: # - images/a_dot_burr.jpeg - assets/icon/ - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart index bb9962f..68a3681 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -7,12 +7,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hosts/app.dart'; import 'package:hosts/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp(fileContent: '',)); + await tester.pumpWidget(HostsApp('',)); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget); diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..4f78848 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..88b22e5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/windows/packaging/exe/make_config.yaml b/windows/packaging/exe/make_config.yaml new file mode 100644 index 0000000..037cc9c --- /dev/null +++ b/windows/packaging/exe/make_config.yaml @@ -0,0 +1,14 @@ +# AppId 的值唯一标识此应用。 +# 不要在其他应用的安装程序中使用相同的 AppId 值。 +app_id: 2E5F8A1B-C4D6-4E3F-8A9B-1C2D3E4F5G6H +publisher: Webb +publisher_url: https://github.com/webb-l/hosts +display_name: Hosts Editor +create_desktop_icon: true +# See: https://jrsoftware.org/ishelp/index.php?topic=setup_defaultdirname +# install_dir_name: "C:\\Program Files\\HostsEditor" +# 这里的路径是相对于项目根目录的路径; 图标格式必须是ico格式,不能是png或其它 +setup_icon_file: windows\runner\resources\app_icon.ico +locales: + - en + - zh \ No newline at end of file diff --git a/windows/packaging/msix/make_config.yaml b/windows/packaging/msix/make_config.yaml new file mode 100644 index 0000000..e5dfeb9 --- /dev/null +++ b/windows/packaging/msix/make_config.yaml @@ -0,0 +1,18 @@ +display_name: Hosts Editor +publisher_display_name: Webb +publisher: CN=Webb +identity_name: top.webb_l.hosts +msix_version: 1.5.0.0 +description: A cross-platform hosts file editor built with Flutter +# logo_path: assets\icon\logo.png +start_menu_icon_path: assets\icon\logo.png +tile_icon_path: assets\icon\logo.png +vs_generated_images_folder_path: assets\icon\ +icons_background_color: transparent +architecture: x64 +certificate_path: +certificate_password: +capabilities: + - internetClient + - privateNetworkClientServer + - documentsLibrary \ No newline at end of file diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20c..107e6d2 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ