diff --git a/.github/workflows/build_linux.yaml b/.github/workflows/build_linux.yaml index 7aba83cd..c65d7c68 100644 --- a/.github/workflows/build_linux.yaml +++ b/.github/workflows/build_linux.yaml @@ -1,5 +1,7 @@ -name: Build Linux -on: [push] +name: Build Linux (Production) +on: + push: + branches: [ main ] jobs: build: name: "Build" @@ -7,6 +9,11 @@ jobs: steps: - name: Check out repository code uses: actions/checkout@v4 + with: + token: ${{ secrets.WQHUB_TOKEN }} + submodules: 'recursive' + - name: Update submodules + run: git submodule update --init --remote --recursive - name: Install linux dependencies run: | sudo apt-get update -y @@ -21,11 +28,14 @@ jobs: uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: 3.27.4 - name: Install Flutter dependencies run: flutter pub get - name: Create debug symbols directory run: mkdir -p out/linux + - name: Patch game client + run: | + rm lib/game_client/game_client_list.dart + mv lib/game_client/game_client_list.dart.PROD lib/game_client/game_client_list.dart - name: Run tests run: flutter test - name: Build diff --git a/.github/workflows/build_macos.yaml b/.github/workflows/build_macos.yaml new file mode 100644 index 00000000..3df27206 --- /dev/null +++ b/.github/workflows/build_macos.yaml @@ -0,0 +1,44 @@ +name: Build MacOS (Production) +on: + push: + branches: [ main ] +jobs: + build: + name: "Build" + runs-on: macos-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + with: + token: ${{ secrets.WQHUB_TOKEN }} + submodules: 'recursive' + - name: Update submodules + run: git submodule update --init --remote --recursive + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + - name: Install Flutter dependencies + run: flutter pub get + - name: Create debug symbols directory + run: mkdir -p out/macos + - name: Patch game client + run: | + rm lib/game_client/game_client_list.dart + mv lib/game_client/game_client_list.dart.PROD lib/game_client/game_client_list.dart + - name: Run tests + run: flutter test + - name: Build + run: flutter build macos --obfuscate --split-debug-info=out/macos + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: WeiqiHub-macos.app + path: build/macos/Build/Products/Release/wqhub.app + retention-days: 7 + - name: Upload debug symbols + uses: actions/upload-artifact@v4 + with: + name: debug-symbols-macos + path: out/macos + retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/build_windows.yaml b/.github/workflows/build_windows.yaml index 35aff401..52431869 100644 --- a/.github/workflows/build_windows.yaml +++ b/.github/workflows/build_windows.yaml @@ -1,21 +1,47 @@ -name: Build Windows -on: [push] +name: Build Windows (Production) +on: + push: + branches: [ main ] jobs: build: name: "Build" - runs-on: windows-latest + runs-on: windows-2022 steps: - name: Check out repository code uses: actions/checkout@v4 + with: + token: ${{ secrets.WQHUB_TOKEN }} + submodules: 'recursive' + - name: Update submodules + run: git submodule update --init --remote --recursive + - name: Find VC Redist Path + id: find_vcredist + shell: pwsh + run: | + $allPaths = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -find "VC\Redist\MSVC\**\x64\Microsoft.VC143.CRT\msvcp140.dll" + $standardPath = $allPaths | Where-Object { + $_ -like "*\x64\Microsoft.VC143.CRT\*" -and + $_ -notlike "*\spectre\*" -and + $_ -notlike "*\onecore\*" + } | Select-Object -First 1 + if (-not $standardPath) { + $standardPath = $allPaths | Select-Object -First 1 + } + $dir = [System.IO.Path]::GetDirectoryName($standardPath) + echo "VCRedistPath=$dir" >> $env:GITHUB_OUTPUT + echo "VC Redist dir: $dir" - name: Setup Flutter uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: 3.27.4 - name: Install Flutter dependencies run: flutter pub get - name: Create debug symbols directory run: mkdir -p out/windows + - name: Patch game client + run: | + rm lib/game_client/game_client_list.dart + mv lib/game_client/game_client_list.dart.PROD lib/game_client/game_client_list.dart - name: Run tests run: flutter test - name: Build @@ -23,7 +49,7 @@ jobs: - name: Copy setup description file run: cp windows/setup.iss build/windows/ - name: Create setup - run: ISCC /O"out" "build/windows/setup.iss" + run: ISCC /DVCRedistPath="${{ steps.find_vcredist.outputs.VCRedistPath }}" /O"out" "build/windows/setup.iss" - name: Upload Windows setup uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/community_ci.yaml b/.github/workflows/community_ci.yaml new file mode 100644 index 00000000..c86e4368 --- /dev/null +++ b/.github/workflows/community_ci.yaml @@ -0,0 +1,52 @@ + +name: Community CI (No Private Dependencies) + +# This workflow provides essential validation for pull requests and pushes, +# including those from external contributors. It runs: +# - Static code analysis (flutter analyze) +# - Unit tests (flutter test) +# +# What it DOESN'T run: +# - Cross-platform builds (Linux/macOS/Windows) +# - Production builds with private game clients (FoxWQ, Tygem) +# - Builds requiring private submodules or tokens +# +# This ensures fast feedback for contributors while keeping the essential +# quality checks that catch most issues. + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + test: + name: "Static Analysis & Tests (${{ matrix.flutter-name }})" + runs-on: ubuntu-latest + strategy: + matrix: + include: + - flutter-version: '' + flutter-name: 'Latest Stable' + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ matrix.flutter-version }} + + - name: Install Flutter dependencies + run: flutter pub get + + - name: Check code formatting + run: dart format --set-exit-if-changed . + + - name: Analyze code + run: flutter analyze + + - name: Run tests + run: flutter test diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..79a89b74 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/game_client/foxwq"] + path = lib/game_client/foxwq + url = https://github.com/ale64bit/WeiqiHub_foxwq.git +[submodule "lib/game_client/tygem"] + path = lib/game_client/tygem + url = https://github.com/ale64bit/WeiqiHub_tygem.git diff --git a/CHANGELOG.md b/CHANGELOG.md index e713bb72..d1cefa07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,27 @@ # Changelog +## 0.1.10 + +## 0.1.9 +- add Next button for topic exams +- fix: redo button bug on custom exams +- fix: disable start custom exam if there are no tasks available +- fix: custom exam reports more mistakes than available +- fix many broken tasks +- improve overall routing/navigation +- improve statistics page: daily/weekly/monthly stats +- new themes by Pumu +- experimental: 9x9 human-like AI bot +- improved sound settings + ## 0.1.8 - add file picker dialog to save games on desktop - add task topics +- fix: starting a collection warns about ongoing sessions +- fix: collections page refreshes after exiting current session +- add mode to try custom moves in tasks +- add Custom Exam mode +- fix several broken tasks ## 0.1.7 - hotfix for Windows game downloads diff --git a/README.md b/README.md index 8b439c71..a5d11635 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Build Linux](https://github.com/ale64bit/WeiqiHub/actions/workflows/build_linux.yaml/badge.svg)](https://github.com/ale64bit/WeiqiHub/actions/workflows/build_linux.yaml) +[![Build MacOS](https://github.com/ale64bit/WeiqiHub/actions/workflows/build_macos.yaml/badge.svg)](https://github.com/ale64bit/WeiqiHub/actions/workflows/build_macos.yaml) [![Build Windows](https://github.com/ale64bit/WeiqiHub/actions/workflows/build_windows.yaml/badge.svg)](https://github.com/ale64bit/WeiqiHub/actions/workflows/build_windows.yaml) WeiqiHub is a unified client to multiple Go servers and offline puzzle solving. @@ -20,8 +21,18 @@ WeiqiHub is a unified client to multiple Go servers and offline puzzle solving. * Time Frenzy: solve as many problems as possible within 3 minutes. Difficulty increases the more you solve. If you make 3 mistakes, you are out. * Ranked Mode: solve problems without a time limit. Difficulty increases the more/faster you solve. * Collections: solve classic curated collections of problems without a time limit to improve your reading strength. + * Topics: solve problems across various topics and ranks. + * Custom Exam: solve problems from a customized set of topics, collections or from your mistakes - Keep track of your solve stats and mistakes ## Development -A working installation of Flutter is needed. To run the app in debug mode, use `flutter run` or the Run button in VSCode. \ No newline at end of file +A working installation of Flutter is needed. To run the app in debug mode, use `flutter run` or the Run button in VSCode. + +### Localization + +In order to localize parts of, or the whole app, to another language contributors are encouraged to modify/add the translation files. Each supported locale is stored as a `app_$LOCALE.arb` file under [lib/l10n](lib/l10n). The [English language](lib/l10n/app_en.arb) serves as the template and is a good starting point when translating to a new language. + +Feel free to open PRs with localization contributions/fixes if you are a developer. If you are not a developer, feel free to send me new/modified `.arb` files directly. + +You can read more about the syntax and use of the localization framework [here](https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization). diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 7bb2df6b..5e6b5427 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index a42444de..23e8403a 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.2.1" apply false + id "com.android.application" version "8.3.0" apply false id "org.jetbrains.kotlin.android" version "1.8.22" apply false } diff --git a/assets/images/board/baduktv.png b/assets/images/board/baduktv.png new file mode 100644 index 00000000..b76138bf Binary files /dev/null and b/assets/images/board/baduktv.png differ diff --git a/assets/images/stones/baduktv_b.png b/assets/images/stones/baduktv_b.png new file mode 100644 index 00000000..77f4af47 Binary files /dev/null and b/assets/images/stones/baduktv_b.png differ diff --git a/assets/images/stones/baduktv_w.png b/assets/images/stones/baduktv_w.png new file mode 100644 index 00000000..d4819fa5 Binary files /dev/null and b/assets/images/stones/baduktv_w.png differ diff --git a/assets/models/play_9x9.tflite b/assets/models/play_9x9.tflite new file mode 100644 index 00000000..7446d110 Binary files /dev/null and b/assets/models/play_9x9.tflite differ diff --git a/assets/models/score_9x9.tflite b/assets/models/score_9x9.tflite new file mode 100644 index 00000000..3296c65f Binary files /dev/null and b/assets/models/score_9x9.tflite differ diff --git a/assets/tasks/15_0.bin b/assets/tasks/15_0.bin index bdcbfcf7..5d1368f7 100644 Binary files a/assets/tasks/15_0.bin and b/assets/tasks/15_0.bin differ diff --git a/assets/tasks/15_1.bin b/assets/tasks/15_1.bin index 1daad54d..7cd2a683 100644 Binary files a/assets/tasks/15_1.bin and b/assets/tasks/15_1.bin differ diff --git a/assets/tasks/15_2.bin b/assets/tasks/15_2.bin index 4e21166f..b2021ef9 100644 Binary files a/assets/tasks/15_2.bin and b/assets/tasks/15_2.bin differ diff --git a/assets/tasks/15_3.bin b/assets/tasks/15_3.bin index ed324fee..eabb8660 100644 Binary files a/assets/tasks/15_3.bin and b/assets/tasks/15_3.bin differ diff --git a/assets/tasks/15_7.bin b/assets/tasks/15_7.bin index 3b9a32fc..d5e99d93 100644 Binary files a/assets/tasks/15_7.bin and b/assets/tasks/15_7.bin differ diff --git a/assets/tasks/15_8.bin b/assets/tasks/15_8.bin index 0964aad0..a2ca9b2e 100644 Binary files a/assets/tasks/15_8.bin and b/assets/tasks/15_8.bin differ diff --git a/assets/tasks/15_9.bin b/assets/tasks/15_9.bin index 88553e25..aaf13af1 100644 Binary files a/assets/tasks/15_9.bin and b/assets/tasks/15_9.bin differ diff --git a/assets/tasks/16_0.bin b/assets/tasks/16_0.bin index 7cc345c7..6bd66583 100644 Binary files a/assets/tasks/16_0.bin and b/assets/tasks/16_0.bin differ diff --git a/assets/tasks/16_1.bin b/assets/tasks/16_1.bin index 7eed617b..d9ee8327 100644 Binary files a/assets/tasks/16_1.bin and b/assets/tasks/16_1.bin differ diff --git a/assets/tasks/16_2.bin b/assets/tasks/16_2.bin index 0d1db89e..a1255fd1 100644 Binary files a/assets/tasks/16_2.bin and b/assets/tasks/16_2.bin differ diff --git a/assets/tasks/16_3.bin b/assets/tasks/16_3.bin index f3dea001..87d01584 100644 Binary files a/assets/tasks/16_3.bin and b/assets/tasks/16_3.bin differ diff --git a/assets/tasks/16_7.bin b/assets/tasks/16_7.bin index 4e18194a..c4a41eb3 100644 Binary files a/assets/tasks/16_7.bin and b/assets/tasks/16_7.bin differ diff --git a/assets/tasks/16_8.bin b/assets/tasks/16_8.bin index 1c6c9af6..35730a79 100644 Binary files a/assets/tasks/16_8.bin and b/assets/tasks/16_8.bin differ diff --git a/assets/tasks/17_0.bin b/assets/tasks/17_0.bin index 7e4cda4a..ac92482f 100644 Binary files a/assets/tasks/17_0.bin and b/assets/tasks/17_0.bin differ diff --git a/assets/tasks/17_1.bin b/assets/tasks/17_1.bin index 7736319e..d73d69b6 100644 Binary files a/assets/tasks/17_1.bin and b/assets/tasks/17_1.bin differ diff --git a/assets/tasks/17_2.bin b/assets/tasks/17_2.bin index 8883b4ba..74e6278b 100644 Binary files a/assets/tasks/17_2.bin and b/assets/tasks/17_2.bin differ diff --git a/assets/tasks/17_3.bin b/assets/tasks/17_3.bin index fbc56648..c55b963d 100644 Binary files a/assets/tasks/17_3.bin and b/assets/tasks/17_3.bin differ diff --git a/assets/tasks/17_7.bin b/assets/tasks/17_7.bin index c4a187ae..6412dcc2 100644 Binary files a/assets/tasks/17_7.bin and b/assets/tasks/17_7.bin differ diff --git a/assets/tasks/17_8.bin b/assets/tasks/17_8.bin index 37c1f270..e8c1cbb6 100644 Binary files a/assets/tasks/17_8.bin and b/assets/tasks/17_8.bin differ diff --git a/assets/tasks/17_9.bin b/assets/tasks/17_9.bin index 5c22464b..ab533149 100644 Binary files a/assets/tasks/17_9.bin and b/assets/tasks/17_9.bin differ diff --git a/assets/tasks/18_0.bin b/assets/tasks/18_0.bin index d48200a0..bd9e3d9c 100644 Binary files a/assets/tasks/18_0.bin and b/assets/tasks/18_0.bin differ diff --git a/assets/tasks/18_1.bin b/assets/tasks/18_1.bin index 62cac59e..6b7b7934 100644 Binary files a/assets/tasks/18_1.bin and b/assets/tasks/18_1.bin differ diff --git a/assets/tasks/18_2.bin b/assets/tasks/18_2.bin index b5480622..9ccdf610 100644 Binary files a/assets/tasks/18_2.bin and b/assets/tasks/18_2.bin differ diff --git a/assets/tasks/18_3.bin b/assets/tasks/18_3.bin index 9926a814..ad16430a 100644 Binary files a/assets/tasks/18_3.bin and b/assets/tasks/18_3.bin differ diff --git a/assets/tasks/18_7.bin b/assets/tasks/18_7.bin index 88f24603..3fe0abcf 100644 Binary files a/assets/tasks/18_7.bin and b/assets/tasks/18_7.bin differ diff --git a/assets/tasks/18_8.bin b/assets/tasks/18_8.bin index fa658024..c410fce8 100644 Binary files a/assets/tasks/18_8.bin and b/assets/tasks/18_8.bin differ diff --git a/assets/tasks/18_9.bin b/assets/tasks/18_9.bin index ef99e1d8..b02b5238 100644 Binary files a/assets/tasks/18_9.bin and b/assets/tasks/18_9.bin differ diff --git a/assets/tasks/19_0.bin b/assets/tasks/19_0.bin index 73595243..a15b15ad 100644 Binary files a/assets/tasks/19_0.bin and b/assets/tasks/19_0.bin differ diff --git a/assets/tasks/19_1.bin b/assets/tasks/19_1.bin index 5e16184b..431b09a8 100644 Binary files a/assets/tasks/19_1.bin and b/assets/tasks/19_1.bin differ diff --git a/assets/tasks/19_2.bin b/assets/tasks/19_2.bin index 514c38de..4e1f5d2d 100644 Binary files a/assets/tasks/19_2.bin and b/assets/tasks/19_2.bin differ diff --git a/assets/tasks/19_3.bin b/assets/tasks/19_3.bin index 5a626c80..ce95fdb0 100644 Binary files a/assets/tasks/19_3.bin and b/assets/tasks/19_3.bin differ diff --git a/assets/tasks/19_7.bin b/assets/tasks/19_7.bin index b4caf1e8..88257184 100644 Binary files a/assets/tasks/19_7.bin and b/assets/tasks/19_7.bin differ diff --git a/assets/tasks/19_8.bin b/assets/tasks/19_8.bin index b10e7c22..eaad81bb 100644 Binary files a/assets/tasks/19_8.bin and b/assets/tasks/19_8.bin differ diff --git a/assets/tasks/19_9.bin b/assets/tasks/19_9.bin index 4ae4ae08..964f7b11 100644 Binary files a/assets/tasks/19_9.bin and b/assets/tasks/19_9.bin differ diff --git a/assets/tasks/20_0.bin b/assets/tasks/20_0.bin index d4024a73..d4d1f57a 100644 Binary files a/assets/tasks/20_0.bin and b/assets/tasks/20_0.bin differ diff --git a/assets/tasks/20_1.bin b/assets/tasks/20_1.bin index 89e8f7bf..2db97e72 100644 Binary files a/assets/tasks/20_1.bin and b/assets/tasks/20_1.bin differ diff --git a/assets/tasks/20_2.bin b/assets/tasks/20_2.bin index fde94cc7..a759813a 100644 Binary files a/assets/tasks/20_2.bin and b/assets/tasks/20_2.bin differ diff --git a/assets/tasks/20_3.bin b/assets/tasks/20_3.bin index 5ccd0e32..c97d3412 100644 Binary files a/assets/tasks/20_3.bin and b/assets/tasks/20_3.bin differ diff --git a/assets/tasks/20_4.bin b/assets/tasks/20_4.bin index 0bc55119..90d71518 100644 Binary files a/assets/tasks/20_4.bin and b/assets/tasks/20_4.bin differ diff --git a/assets/tasks/20_5.bin b/assets/tasks/20_5.bin index cbec85a5..c4f54257 100644 Binary files a/assets/tasks/20_5.bin and b/assets/tasks/20_5.bin differ diff --git a/assets/tasks/20_7.bin b/assets/tasks/20_7.bin index 806897be..fa0599c6 100644 Binary files a/assets/tasks/20_7.bin and b/assets/tasks/20_7.bin differ diff --git a/assets/tasks/20_8.bin b/assets/tasks/20_8.bin index a1ebbf40..012f2f12 100644 Binary files a/assets/tasks/20_8.bin and b/assets/tasks/20_8.bin differ diff --git a/assets/tasks/20_9.bin b/assets/tasks/20_9.bin index 288959d0..4650eab3 100644 Binary files a/assets/tasks/20_9.bin and b/assets/tasks/20_9.bin differ diff --git a/assets/tasks/21_0.bin b/assets/tasks/21_0.bin index a99f494b..5d9b19f3 100644 Binary files a/assets/tasks/21_0.bin and b/assets/tasks/21_0.bin differ diff --git a/assets/tasks/21_1.bin b/assets/tasks/21_1.bin index 2f596fc1..787ad3f0 100644 Binary files a/assets/tasks/21_1.bin and b/assets/tasks/21_1.bin differ diff --git a/assets/tasks/21_2.bin b/assets/tasks/21_2.bin index 1ec2c2b7..df7f0dc3 100644 Binary files a/assets/tasks/21_2.bin and b/assets/tasks/21_2.bin differ diff --git a/assets/tasks/21_3.bin b/assets/tasks/21_3.bin index 66d2a402..d4970ac1 100644 Binary files a/assets/tasks/21_3.bin and b/assets/tasks/21_3.bin differ diff --git a/assets/tasks/21_4.bin b/assets/tasks/21_4.bin index d8b36eee..0458c026 100644 Binary files a/assets/tasks/21_4.bin and b/assets/tasks/21_4.bin differ diff --git a/assets/tasks/21_7.bin b/assets/tasks/21_7.bin index b1b8d5ea..b0113334 100644 Binary files a/assets/tasks/21_7.bin and b/assets/tasks/21_7.bin differ diff --git a/assets/tasks/21_8.bin b/assets/tasks/21_8.bin index db604087..5bf02e03 100644 Binary files a/assets/tasks/21_8.bin and b/assets/tasks/21_8.bin differ diff --git a/assets/tasks/21_9.bin b/assets/tasks/21_9.bin index e0e9e8fe..bc364bb8 100644 Binary files a/assets/tasks/21_9.bin and b/assets/tasks/21_9.bin differ diff --git a/assets/tasks/22_0.bin b/assets/tasks/22_0.bin index daa60b7c..09577ba4 100644 Binary files a/assets/tasks/22_0.bin and b/assets/tasks/22_0.bin differ diff --git a/assets/tasks/22_1.bin b/assets/tasks/22_1.bin index 912d74e5..1a433420 100644 Binary files a/assets/tasks/22_1.bin and b/assets/tasks/22_1.bin differ diff --git a/assets/tasks/22_2.bin b/assets/tasks/22_2.bin index b6b0ca5a..575a9f44 100644 Binary files a/assets/tasks/22_2.bin and b/assets/tasks/22_2.bin differ diff --git a/assets/tasks/22_3.bin b/assets/tasks/22_3.bin index 737b24f6..8eca5323 100644 Binary files a/assets/tasks/22_3.bin and b/assets/tasks/22_3.bin differ diff --git a/assets/tasks/22_4.bin b/assets/tasks/22_4.bin index 31bc75f1..c144e287 100644 Binary files a/assets/tasks/22_4.bin and b/assets/tasks/22_4.bin differ diff --git a/assets/tasks/22_5.bin b/assets/tasks/22_5.bin index d5a34281..3f672cda 100644 Binary files a/assets/tasks/22_5.bin and b/assets/tasks/22_5.bin differ diff --git a/assets/tasks/22_7.bin b/assets/tasks/22_7.bin index 69616d08..3dcb6833 100644 Binary files a/assets/tasks/22_7.bin and b/assets/tasks/22_7.bin differ diff --git a/assets/tasks/22_8.bin b/assets/tasks/22_8.bin index 97444b98..30344f6c 100644 Binary files a/assets/tasks/22_8.bin and b/assets/tasks/22_8.bin differ diff --git a/assets/tasks/22_9.bin b/assets/tasks/22_9.bin index 04d56a80..34e3c5ad 100644 Binary files a/assets/tasks/22_9.bin and b/assets/tasks/22_9.bin differ diff --git a/assets/tasks/23_0.bin b/assets/tasks/23_0.bin index 00d36ba9..94e6fe49 100644 Binary files a/assets/tasks/23_0.bin and b/assets/tasks/23_0.bin differ diff --git a/assets/tasks/23_1.bin b/assets/tasks/23_1.bin index 1f2396ed..4a613dca 100644 Binary files a/assets/tasks/23_1.bin and b/assets/tasks/23_1.bin differ diff --git a/assets/tasks/23_2.bin b/assets/tasks/23_2.bin index ffb958d5..afabe19d 100644 Binary files a/assets/tasks/23_2.bin and b/assets/tasks/23_2.bin differ diff --git a/assets/tasks/23_3.bin b/assets/tasks/23_3.bin index 35c2196f..fe9077ae 100644 Binary files a/assets/tasks/23_3.bin and b/assets/tasks/23_3.bin differ diff --git a/assets/tasks/23_4.bin b/assets/tasks/23_4.bin index 67e96591..f781d72d 100644 Binary files a/assets/tasks/23_4.bin and b/assets/tasks/23_4.bin differ diff --git a/assets/tasks/23_5.bin b/assets/tasks/23_5.bin index 38f897ad..6c750d58 100644 Binary files a/assets/tasks/23_5.bin and b/assets/tasks/23_5.bin differ diff --git a/assets/tasks/23_7.bin b/assets/tasks/23_7.bin index ace67bb4..815d0e57 100644 Binary files a/assets/tasks/23_7.bin and b/assets/tasks/23_7.bin differ diff --git a/assets/tasks/23_8.bin b/assets/tasks/23_8.bin index 2f6da422..4e38b596 100644 Binary files a/assets/tasks/23_8.bin and b/assets/tasks/23_8.bin differ diff --git a/assets/tasks/23_9.bin b/assets/tasks/23_9.bin index 042efc96..6ee205e9 100644 Binary files a/assets/tasks/23_9.bin and b/assets/tasks/23_9.bin differ diff --git a/assets/tasks/24_0.bin b/assets/tasks/24_0.bin index cee6c7b9..a33df50e 100644 Binary files a/assets/tasks/24_0.bin and b/assets/tasks/24_0.bin differ diff --git a/assets/tasks/24_1.bin b/assets/tasks/24_1.bin index 1d7993a7..cb73046f 100644 Binary files a/assets/tasks/24_1.bin and b/assets/tasks/24_1.bin differ diff --git a/assets/tasks/24_2.bin b/assets/tasks/24_2.bin index c94b9aff..54f70870 100644 Binary files a/assets/tasks/24_2.bin and b/assets/tasks/24_2.bin differ diff --git a/assets/tasks/24_3.bin b/assets/tasks/24_3.bin index 311b57c6..59eb445a 100644 Binary files a/assets/tasks/24_3.bin and b/assets/tasks/24_3.bin differ diff --git a/assets/tasks/24_4.bin b/assets/tasks/24_4.bin index 95c9331c..0319293f 100644 Binary files a/assets/tasks/24_4.bin and b/assets/tasks/24_4.bin differ diff --git a/assets/tasks/24_5.bin b/assets/tasks/24_5.bin index efc970b7..6eb1a3ad 100644 Binary files a/assets/tasks/24_5.bin and b/assets/tasks/24_5.bin differ diff --git a/assets/tasks/24_7.bin b/assets/tasks/24_7.bin index bcb47e40..ef7d3f35 100644 Binary files a/assets/tasks/24_7.bin and b/assets/tasks/24_7.bin differ diff --git a/assets/tasks/24_8.bin b/assets/tasks/24_8.bin index bb692b50..c3a5774b 100644 Binary files a/assets/tasks/24_8.bin and b/assets/tasks/24_8.bin differ diff --git a/assets/tasks/24_9.bin b/assets/tasks/24_9.bin index 41481427..1ea50e2c 100644 Binary files a/assets/tasks/24_9.bin and b/assets/tasks/24_9.bin differ diff --git a/assets/tasks/25_0.bin b/assets/tasks/25_0.bin index 7910de9e..8cef76e3 100644 Binary files a/assets/tasks/25_0.bin and b/assets/tasks/25_0.bin differ diff --git a/assets/tasks/25_1.bin b/assets/tasks/25_1.bin index 1ffdc72a..078b4b75 100644 Binary files a/assets/tasks/25_1.bin and b/assets/tasks/25_1.bin differ diff --git a/assets/tasks/25_2.bin b/assets/tasks/25_2.bin index 0b003c42..692396d4 100644 Binary files a/assets/tasks/25_2.bin and b/assets/tasks/25_2.bin differ diff --git a/assets/tasks/25_3.bin b/assets/tasks/25_3.bin index 7a837d03..ee984ae0 100644 Binary files a/assets/tasks/25_3.bin and b/assets/tasks/25_3.bin differ diff --git a/assets/tasks/25_4.bin b/assets/tasks/25_4.bin index c8040ffa..f2c66fc4 100644 Binary files a/assets/tasks/25_4.bin and b/assets/tasks/25_4.bin differ diff --git a/assets/tasks/25_5.bin b/assets/tasks/25_5.bin index 2c32ec41..bb52326e 100644 Binary files a/assets/tasks/25_5.bin and b/assets/tasks/25_5.bin differ diff --git a/assets/tasks/25_7.bin b/assets/tasks/25_7.bin index 7174b554..47e674b3 100644 Binary files a/assets/tasks/25_7.bin and b/assets/tasks/25_7.bin differ diff --git a/assets/tasks/25_8.bin b/assets/tasks/25_8.bin index 0ca8a989..62959921 100644 Binary files a/assets/tasks/25_8.bin and b/assets/tasks/25_8.bin differ diff --git a/assets/tasks/25_9.bin b/assets/tasks/25_9.bin index 507db863..5842fc9f 100644 Binary files a/assets/tasks/25_9.bin and b/assets/tasks/25_9.bin differ diff --git a/assets/tasks/26_0.bin b/assets/tasks/26_0.bin index da4dbf0e..492ebcf8 100644 Binary files a/assets/tasks/26_0.bin and b/assets/tasks/26_0.bin differ diff --git a/assets/tasks/26_1.bin b/assets/tasks/26_1.bin index d2155573..60f60115 100644 Binary files a/assets/tasks/26_1.bin and b/assets/tasks/26_1.bin differ diff --git a/assets/tasks/26_2.bin b/assets/tasks/26_2.bin index 885e6cec..c61cda24 100644 Binary files a/assets/tasks/26_2.bin and b/assets/tasks/26_2.bin differ diff --git a/assets/tasks/26_3.bin b/assets/tasks/26_3.bin index 97d080f9..455122f0 100644 Binary files a/assets/tasks/26_3.bin and b/assets/tasks/26_3.bin differ diff --git a/assets/tasks/26_4.bin b/assets/tasks/26_4.bin index 2d0f5569..14f70f41 100644 Binary files a/assets/tasks/26_4.bin and b/assets/tasks/26_4.bin differ diff --git a/assets/tasks/26_5.bin b/assets/tasks/26_5.bin index 3d7c4c69..b7a2f266 100644 Binary files a/assets/tasks/26_5.bin and b/assets/tasks/26_5.bin differ diff --git a/assets/tasks/26_6.bin b/assets/tasks/26_6.bin index c36e8169..da39fd19 100644 Binary files a/assets/tasks/26_6.bin and b/assets/tasks/26_6.bin differ diff --git a/assets/tasks/26_7.bin b/assets/tasks/26_7.bin index 3ba88b60..0b00f547 100644 Binary files a/assets/tasks/26_7.bin and b/assets/tasks/26_7.bin differ diff --git a/assets/tasks/26_8.bin b/assets/tasks/26_8.bin index 89040ef7..1575fd14 100644 Binary files a/assets/tasks/26_8.bin and b/assets/tasks/26_8.bin differ diff --git a/assets/tasks/26_9.bin b/assets/tasks/26_9.bin index f891adea..33fadb0a 100644 Binary files a/assets/tasks/26_9.bin and b/assets/tasks/26_9.bin differ diff --git a/assets/tasks/27_0.bin b/assets/tasks/27_0.bin index f313df8f..f681b557 100644 Binary files a/assets/tasks/27_0.bin and b/assets/tasks/27_0.bin differ diff --git a/assets/tasks/27_1.bin b/assets/tasks/27_1.bin index 870137c2..58b410e6 100644 Binary files a/assets/tasks/27_1.bin and b/assets/tasks/27_1.bin differ diff --git a/assets/tasks/27_2.bin b/assets/tasks/27_2.bin index dce5a1a8..bbd3e737 100644 Binary files a/assets/tasks/27_2.bin and b/assets/tasks/27_2.bin differ diff --git a/assets/tasks/27_3.bin b/assets/tasks/27_3.bin index 1778b114..afd261e9 100644 Binary files a/assets/tasks/27_3.bin and b/assets/tasks/27_3.bin differ diff --git a/assets/tasks/27_4.bin b/assets/tasks/27_4.bin index fc6ab833..a555cb49 100644 Binary files a/assets/tasks/27_4.bin and b/assets/tasks/27_4.bin differ diff --git a/assets/tasks/27_5.bin b/assets/tasks/27_5.bin index 8f37cbb0..77db2977 100644 Binary files a/assets/tasks/27_5.bin and b/assets/tasks/27_5.bin differ diff --git a/assets/tasks/27_6.bin b/assets/tasks/27_6.bin index b69dc7cf..d6c59ee6 100644 Binary files a/assets/tasks/27_6.bin and b/assets/tasks/27_6.bin differ diff --git a/assets/tasks/27_7.bin b/assets/tasks/27_7.bin index b7e9eab3..0af7ee04 100644 Binary files a/assets/tasks/27_7.bin and b/assets/tasks/27_7.bin differ diff --git a/assets/tasks/27_8.bin b/assets/tasks/27_8.bin index b0a8f831..417b6c89 100644 Binary files a/assets/tasks/27_8.bin and b/assets/tasks/27_8.bin differ diff --git a/assets/tasks/27_9.bin b/assets/tasks/27_9.bin index 57a3277f..78f3aafd 100644 Binary files a/assets/tasks/27_9.bin and b/assets/tasks/27_9.bin differ diff --git a/assets/tasks/28_0.bin b/assets/tasks/28_0.bin index b3817a57..99cc7ed1 100644 Binary files a/assets/tasks/28_0.bin and b/assets/tasks/28_0.bin differ diff --git a/assets/tasks/28_1.bin b/assets/tasks/28_1.bin index 5cbd0207..c769abb8 100644 Binary files a/assets/tasks/28_1.bin and b/assets/tasks/28_1.bin differ diff --git a/assets/tasks/28_2.bin b/assets/tasks/28_2.bin index 16ed7106..fe8313dd 100644 Binary files a/assets/tasks/28_2.bin and b/assets/tasks/28_2.bin differ diff --git a/assets/tasks/28_3.bin b/assets/tasks/28_3.bin index 87b84dfa..4fac5879 100644 Binary files a/assets/tasks/28_3.bin and b/assets/tasks/28_3.bin differ diff --git a/assets/tasks/28_4.bin b/assets/tasks/28_4.bin index cda7dda0..7eb95348 100644 Binary files a/assets/tasks/28_4.bin and b/assets/tasks/28_4.bin differ diff --git a/assets/tasks/28_5.bin b/assets/tasks/28_5.bin index b854ae95..a002eedd 100644 Binary files a/assets/tasks/28_5.bin and b/assets/tasks/28_5.bin differ diff --git a/assets/tasks/28_6.bin b/assets/tasks/28_6.bin index d743fede..d434f5d9 100644 Binary files a/assets/tasks/28_6.bin and b/assets/tasks/28_6.bin differ diff --git a/assets/tasks/28_7.bin b/assets/tasks/28_7.bin index 23241da7..a80821d1 100644 Binary files a/assets/tasks/28_7.bin and b/assets/tasks/28_7.bin differ diff --git a/assets/tasks/28_8.bin b/assets/tasks/28_8.bin index 62b71e55..6ca78182 100644 Binary files a/assets/tasks/28_8.bin and b/assets/tasks/28_8.bin differ diff --git a/assets/tasks/28_9.bin b/assets/tasks/28_9.bin index 49612774..9c40d903 100644 Binary files a/assets/tasks/28_9.bin and b/assets/tasks/28_9.bin differ diff --git a/assets/tasks/29_0.bin b/assets/tasks/29_0.bin index 571f8e25..a5deec68 100644 Binary files a/assets/tasks/29_0.bin and b/assets/tasks/29_0.bin differ diff --git a/assets/tasks/29_1.bin b/assets/tasks/29_1.bin index 4adfe6ff..53255c8e 100644 Binary files a/assets/tasks/29_1.bin and b/assets/tasks/29_1.bin differ diff --git a/assets/tasks/29_2.bin b/assets/tasks/29_2.bin index 42f70bdc..a73a5c62 100644 Binary files a/assets/tasks/29_2.bin and b/assets/tasks/29_2.bin differ diff --git a/assets/tasks/29_3.bin b/assets/tasks/29_3.bin index dd4254b0..6e66f1ff 100644 Binary files a/assets/tasks/29_3.bin and b/assets/tasks/29_3.bin differ diff --git a/assets/tasks/29_4.bin b/assets/tasks/29_4.bin index 9e236cc4..0e376b22 100644 Binary files a/assets/tasks/29_4.bin and b/assets/tasks/29_4.bin differ diff --git a/assets/tasks/29_5.bin b/assets/tasks/29_5.bin index 5e9ff784..8c7558c2 100644 Binary files a/assets/tasks/29_5.bin and b/assets/tasks/29_5.bin differ diff --git a/assets/tasks/29_6.bin b/assets/tasks/29_6.bin index 88e9db5c..127844f2 100644 Binary files a/assets/tasks/29_6.bin and b/assets/tasks/29_6.bin differ diff --git a/assets/tasks/29_7.bin b/assets/tasks/29_7.bin index 918bbe49..de0a06f3 100644 Binary files a/assets/tasks/29_7.bin and b/assets/tasks/29_7.bin differ diff --git a/assets/tasks/29_8.bin b/assets/tasks/29_8.bin index 8d981e0c..8c05ded3 100644 Binary files a/assets/tasks/29_8.bin and b/assets/tasks/29_8.bin differ diff --git a/assets/tasks/29_9.bin b/assets/tasks/29_9.bin index e0d52c8e..7aa45c38 100644 Binary files a/assets/tasks/29_9.bin and b/assets/tasks/29_9.bin differ diff --git a/assets/tasks/30_0.bin b/assets/tasks/30_0.bin index 8844efd8..2fc953c4 100644 Binary files a/assets/tasks/30_0.bin and b/assets/tasks/30_0.bin differ diff --git a/assets/tasks/30_1.bin b/assets/tasks/30_1.bin index 43da297c..5eef226f 100644 Binary files a/assets/tasks/30_1.bin and b/assets/tasks/30_1.bin differ diff --git a/assets/tasks/30_2.bin b/assets/tasks/30_2.bin index 3f172e5e..26745e25 100644 Binary files a/assets/tasks/30_2.bin and b/assets/tasks/30_2.bin differ diff --git a/assets/tasks/30_3.bin b/assets/tasks/30_3.bin index 2ec43311..d7bf775e 100644 Binary files a/assets/tasks/30_3.bin and b/assets/tasks/30_3.bin differ diff --git a/assets/tasks/30_4.bin b/assets/tasks/30_4.bin index 9fd35d70..c90ce078 100644 Binary files a/assets/tasks/30_4.bin and b/assets/tasks/30_4.bin differ diff --git a/assets/tasks/30_5.bin b/assets/tasks/30_5.bin index 4d24b2bf..7a99e957 100644 Binary files a/assets/tasks/30_5.bin and b/assets/tasks/30_5.bin differ diff --git a/assets/tasks/30_6.bin b/assets/tasks/30_6.bin index 795b40c0..b1290525 100644 Binary files a/assets/tasks/30_6.bin and b/assets/tasks/30_6.bin differ diff --git a/assets/tasks/30_7.bin b/assets/tasks/30_7.bin index b14d41c9..63f2e542 100644 Binary files a/assets/tasks/30_7.bin and b/assets/tasks/30_7.bin differ diff --git a/assets/tasks/30_8.bin b/assets/tasks/30_8.bin index ded705ee..ece6d9fb 100644 Binary files a/assets/tasks/30_8.bin and b/assets/tasks/30_8.bin differ diff --git a/assets/tasks/30_9.bin b/assets/tasks/30_9.bin index 1bd0228b..93784584 100644 Binary files a/assets/tasks/30_9.bin and b/assets/tasks/30_9.bin differ diff --git a/assets/tasks/31_0.bin b/assets/tasks/31_0.bin index 0f72993b..555df62e 100644 Binary files a/assets/tasks/31_0.bin and b/assets/tasks/31_0.bin differ diff --git a/assets/tasks/31_1.bin b/assets/tasks/31_1.bin index cd22bdb6..9f7fdabe 100644 Binary files a/assets/tasks/31_1.bin and b/assets/tasks/31_1.bin differ diff --git a/assets/tasks/31_2.bin b/assets/tasks/31_2.bin index 84f4ab5c..b35de778 100644 Binary files a/assets/tasks/31_2.bin and b/assets/tasks/31_2.bin differ diff --git a/assets/tasks/31_3.bin b/assets/tasks/31_3.bin index 13064414..c4eb0d3e 100644 Binary files a/assets/tasks/31_3.bin and b/assets/tasks/31_3.bin differ diff --git a/assets/tasks/31_4.bin b/assets/tasks/31_4.bin index aca018f8..0baf5428 100644 Binary files a/assets/tasks/31_4.bin and b/assets/tasks/31_4.bin differ diff --git a/assets/tasks/31_5.bin b/assets/tasks/31_5.bin index c9045350..efc3132d 100644 Binary files a/assets/tasks/31_5.bin and b/assets/tasks/31_5.bin differ diff --git a/assets/tasks/31_6.bin b/assets/tasks/31_6.bin index 64e9b758..5f019c1a 100644 Binary files a/assets/tasks/31_6.bin and b/assets/tasks/31_6.bin differ diff --git a/assets/tasks/31_7.bin b/assets/tasks/31_7.bin index 5a8e288f..9d598487 100644 Binary files a/assets/tasks/31_7.bin and b/assets/tasks/31_7.bin differ diff --git a/assets/tasks/31_8.bin b/assets/tasks/31_8.bin index 583764cf..0c425bf8 100644 Binary files a/assets/tasks/31_8.bin and b/assets/tasks/31_8.bin differ diff --git a/assets/tasks/31_9.bin b/assets/tasks/31_9.bin index 6dbfd7e9..c5104b5e 100644 Binary files a/assets/tasks/31_9.bin and b/assets/tasks/31_9.bin differ diff --git a/assets/tasks/32_0.bin b/assets/tasks/32_0.bin index fe3c4169..5271575f 100644 Binary files a/assets/tasks/32_0.bin and b/assets/tasks/32_0.bin differ diff --git a/assets/tasks/32_1.bin b/assets/tasks/32_1.bin index 402ef7a4..e66c2ef3 100644 Binary files a/assets/tasks/32_1.bin and b/assets/tasks/32_1.bin differ diff --git a/assets/tasks/32_2.bin b/assets/tasks/32_2.bin index f9871ca6..f874b317 100644 Binary files a/assets/tasks/32_2.bin and b/assets/tasks/32_2.bin differ diff --git a/assets/tasks/32_3.bin b/assets/tasks/32_3.bin index a24b7303..52a71cab 100644 Binary files a/assets/tasks/32_3.bin and b/assets/tasks/32_3.bin differ diff --git a/assets/tasks/32_4.bin b/assets/tasks/32_4.bin index 8e81f817..4d648f22 100644 Binary files a/assets/tasks/32_4.bin and b/assets/tasks/32_4.bin differ diff --git a/assets/tasks/32_5.bin b/assets/tasks/32_5.bin index 7b7bb5b5..c15078ce 100644 Binary files a/assets/tasks/32_5.bin and b/assets/tasks/32_5.bin differ diff --git a/assets/tasks/32_6.bin b/assets/tasks/32_6.bin index 4c9b7758..3dc3ae03 100644 Binary files a/assets/tasks/32_6.bin and b/assets/tasks/32_6.bin differ diff --git a/assets/tasks/32_7.bin b/assets/tasks/32_7.bin index 2d524b4c..6c5d46ea 100644 Binary files a/assets/tasks/32_7.bin and b/assets/tasks/32_7.bin differ diff --git a/assets/tasks/32_8.bin b/assets/tasks/32_8.bin index 6463110a..370db851 100644 Binary files a/assets/tasks/32_8.bin and b/assets/tasks/32_8.bin differ diff --git a/assets/tasks/32_9.bin b/assets/tasks/32_9.bin index d6ba3618..f242102a 100644 Binary files a/assets/tasks/32_9.bin and b/assets/tasks/32_9.bin differ diff --git a/assets/tasks/33_0.bin b/assets/tasks/33_0.bin index a9eea3c3..fc008916 100644 Binary files a/assets/tasks/33_0.bin and b/assets/tasks/33_0.bin differ diff --git a/assets/tasks/33_1.bin b/assets/tasks/33_1.bin index c1d4282e..199c6db9 100644 Binary files a/assets/tasks/33_1.bin and b/assets/tasks/33_1.bin differ diff --git a/assets/tasks/33_2.bin b/assets/tasks/33_2.bin index e8304213..089427c1 100644 Binary files a/assets/tasks/33_2.bin and b/assets/tasks/33_2.bin differ diff --git a/assets/tasks/33_3.bin b/assets/tasks/33_3.bin index dde71473..9e0c1772 100644 Binary files a/assets/tasks/33_3.bin and b/assets/tasks/33_3.bin differ diff --git a/assets/tasks/33_4.bin b/assets/tasks/33_4.bin index 11d0918e..73ae1d3c 100644 Binary files a/assets/tasks/33_4.bin and b/assets/tasks/33_4.bin differ diff --git a/assets/tasks/33_5.bin b/assets/tasks/33_5.bin index bd8dd128..9033f825 100644 Binary files a/assets/tasks/33_5.bin and b/assets/tasks/33_5.bin differ diff --git a/assets/tasks/33_6.bin b/assets/tasks/33_6.bin index 87bad634..bd1fa97a 100644 Binary files a/assets/tasks/33_6.bin and b/assets/tasks/33_6.bin differ diff --git a/assets/tasks/33_7.bin b/assets/tasks/33_7.bin index ecb0c5d4..6089215f 100644 Binary files a/assets/tasks/33_7.bin and b/assets/tasks/33_7.bin differ diff --git a/assets/tasks/33_8.bin b/assets/tasks/33_8.bin index 1ca990a0..027d91cc 100644 Binary files a/assets/tasks/33_8.bin and b/assets/tasks/33_8.bin differ diff --git a/assets/tasks/33_9.bin b/assets/tasks/33_9.bin index 2f1bb782..f35e1225 100644 Binary files a/assets/tasks/33_9.bin and b/assets/tasks/33_9.bin differ diff --git a/assets/tasks/34_0.bin b/assets/tasks/34_0.bin index dbb3a9e0..3b0f56d6 100644 Binary files a/assets/tasks/34_0.bin and b/assets/tasks/34_0.bin differ diff --git a/assets/tasks/34_1.bin b/assets/tasks/34_1.bin index c28dac19..af649585 100644 Binary files a/assets/tasks/34_1.bin and b/assets/tasks/34_1.bin differ diff --git a/assets/tasks/34_2.bin b/assets/tasks/34_2.bin index 2d676480..e238ccf0 100644 Binary files a/assets/tasks/34_2.bin and b/assets/tasks/34_2.bin differ diff --git a/assets/tasks/34_3.bin b/assets/tasks/34_3.bin index 5263b877..486018e1 100644 Binary files a/assets/tasks/34_3.bin and b/assets/tasks/34_3.bin differ diff --git a/assets/tasks/34_4.bin b/assets/tasks/34_4.bin index d46bdc23..0fe236b7 100644 Binary files a/assets/tasks/34_4.bin and b/assets/tasks/34_4.bin differ diff --git a/assets/tasks/34_5.bin b/assets/tasks/34_5.bin index cb66cfaa..42fbbed0 100644 Binary files a/assets/tasks/34_5.bin and b/assets/tasks/34_5.bin differ diff --git a/assets/tasks/34_6.bin b/assets/tasks/34_6.bin index 25a6fdc0..575e259a 100644 Binary files a/assets/tasks/34_6.bin and b/assets/tasks/34_6.bin differ diff --git a/assets/tasks/34_7.bin b/assets/tasks/34_7.bin index 842e38f4..615d3208 100644 Binary files a/assets/tasks/34_7.bin and b/assets/tasks/34_7.bin differ diff --git a/assets/tasks/34_8.bin b/assets/tasks/34_8.bin index d5a09473..ccc6a20f 100644 Binary files a/assets/tasks/34_8.bin and b/assets/tasks/34_8.bin differ diff --git a/assets/tasks/34_9.bin b/assets/tasks/34_9.bin index dc6a15b8..001d881e 100644 Binary files a/assets/tasks/34_9.bin and b/assets/tasks/34_9.bin differ diff --git a/assets/tasks/35_0.bin b/assets/tasks/35_0.bin index dabd74d0..ff4f61ec 100644 Binary files a/assets/tasks/35_0.bin and b/assets/tasks/35_0.bin differ diff --git a/assets/tasks/35_1.bin b/assets/tasks/35_1.bin index befff1ee..9f3943b8 100644 Binary files a/assets/tasks/35_1.bin and b/assets/tasks/35_1.bin differ diff --git a/assets/tasks/35_2.bin b/assets/tasks/35_2.bin index 14b95210..cb273741 100644 Binary files a/assets/tasks/35_2.bin and b/assets/tasks/35_2.bin differ diff --git a/assets/tasks/35_3.bin b/assets/tasks/35_3.bin index 5ec8b490..be1cee7a 100644 Binary files a/assets/tasks/35_3.bin and b/assets/tasks/35_3.bin differ diff --git a/assets/tasks/35_4.bin b/assets/tasks/35_4.bin index c35632d2..ae328bb5 100644 Binary files a/assets/tasks/35_4.bin and b/assets/tasks/35_4.bin differ diff --git a/assets/tasks/35_5.bin b/assets/tasks/35_5.bin index 345b6c73..3fb0decf 100644 Binary files a/assets/tasks/35_5.bin and b/assets/tasks/35_5.bin differ diff --git a/assets/tasks/35_6.bin b/assets/tasks/35_6.bin index f21d5826..e4e65959 100644 Binary files a/assets/tasks/35_6.bin and b/assets/tasks/35_6.bin differ diff --git a/assets/tasks/35_7.bin b/assets/tasks/35_7.bin index 14b82dfe..1b8afca4 100644 Binary files a/assets/tasks/35_7.bin and b/assets/tasks/35_7.bin differ diff --git a/assets/tasks/35_9.bin b/assets/tasks/35_9.bin index 622c9cee..1e515da7 100644 Binary files a/assets/tasks/35_9.bin and b/assets/tasks/35_9.bin differ diff --git a/assets/tasks/36_0.bin b/assets/tasks/36_0.bin index 3a24adbf..9e0ce30a 100644 Binary files a/assets/tasks/36_0.bin and b/assets/tasks/36_0.bin differ diff --git a/assets/tasks/36_1.bin b/assets/tasks/36_1.bin index ec69b312..9532d4fe 100644 Binary files a/assets/tasks/36_1.bin and b/assets/tasks/36_1.bin differ diff --git a/assets/tasks/36_3.bin b/assets/tasks/36_3.bin index 71ab14db..adf97368 100644 Binary files a/assets/tasks/36_3.bin and b/assets/tasks/36_3.bin differ diff --git a/assets/tasks/36_5.bin b/assets/tasks/36_5.bin index 056c1539..6ca5eded 100644 Binary files a/assets/tasks/36_5.bin and b/assets/tasks/36_5.bin differ diff --git a/assets/tasks/36_6.bin b/assets/tasks/36_6.bin index 62ab1d1e..53c5c673 100644 Binary files a/assets/tasks/36_6.bin and b/assets/tasks/36_6.bin differ diff --git a/assets/tasks/36_7.bin b/assets/tasks/36_7.bin index fcd1969c..932d64cc 100644 Binary files a/assets/tasks/36_7.bin and b/assets/tasks/36_7.bin differ diff --git a/assets/tasks/36_9.bin b/assets/tasks/36_9.bin index 66e6c7cf..23ec5f24 100644 Binary files a/assets/tasks/36_9.bin and b/assets/tasks/36_9.bin differ diff --git a/assets/tasks/col.bin b/assets/tasks/col.bin index 1f65c785..239082ff 100644 Binary files a/assets/tasks/col.bin and b/assets/tasks/col.bin differ diff --git a/assets/tasks/tag.bin b/assets/tasks/tag.bin index e4d35338..9e592893 100644 Binary files a/assets/tasks/tag.bin and b/assets/tasks/tag.bin differ diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 00000000..4e6692e5 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart \ No newline at end of file diff --git a/lib/audio/audio_controller.dart b/lib/audio/audio_controller.dart index aebc8b3e..9acdb7de 100644 --- a/lib/audio/audio_controller.dart +++ b/lib/audio/audio_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter_soloud/flutter_soloud.dart'; import 'package:logging/logging.dart'; +import 'package:wqhub/settings/settings.dart'; import 'package:wqhub/wq/game_tree.dart'; class _LocalizedVoice { @@ -25,7 +26,11 @@ class AudioController { final AudioSource _wrong; final _LocalizedVoice _enVoice; - static init() async { + var stoneVolume = 1.0; + var voiceVolume = 1.0; + var uiVolume = 1.0; + + static init(Settings settings) async { _log.info('init'); assert(_instance == null); final soloud = SoLoud.instance; @@ -46,6 +51,9 @@ class AudioController { soloud.loadAsset('assets/sounds/en/$i.mp3') ]), ), + stoneVolume: settings.soundStone, + voiceVolume: settings.soundVoice, + uiVolume: settings.soundUI, ); } @@ -57,6 +65,9 @@ class AudioController { required AudioSource correct, required AudioSource wrong, required _LocalizedVoice enVoice, + required this.stoneVolume, + required this.voiceVolume, + required this.uiVolume, }) : _stone = stone, _captureOne = captureOne, _captureMany = captureMany, @@ -68,9 +79,14 @@ class AudioController { _soloud.deinit(); } - Future playStone() async => await _soloud.play(_stone); - Future captureOne() async => await _soloud.play(_captureOne); - Future captureMany() async => await _soloud.play(_captureMany); + //================================================================================ + // Stone sounds + Future playStone() async => + await _soloud.play(_stone, volume: stoneVolume); + Future captureOne() async => + await _soloud.play(_captureOne, volume: stoneVolume); + Future captureMany() async => + await _soloud.play(_captureMany, volume: stoneVolume); Future playForNode(GameTreeNode node) async { playStone(); @@ -83,10 +99,18 @@ class AudioController { } } - Future correct() async => await _soloud.play(_correct); - Future wrong() async => await _soloud.play(_wrong); + //================================================================================ + // UI sounds + Future correct() async => + await _soloud.play(_correct, volume: uiVolume); + Future wrong() async => await _soloud.play(_wrong, volume: uiVolume); - Future startToPlay() async => await _soloud.play(_enVoice.startToPlay); - Future pass() async => await _soloud.play(_enVoice.pass); - Future count(int i) async => await _soloud.play(_enVoice.count[i - 1]); + //================================================================================ + // Voice sounds + Future startToPlay() async => + await _soloud.play(_enVoice.startToPlay, volume: voiceVolume); + Future pass() async => + await _soloud.play(_enVoice.pass, volume: voiceVolume); + Future count(int i) async => + await _soloud.play(_enVoice.count[i - 1], volume: voiceVolume); } diff --git a/lib/board/board.dart b/lib/board/board.dart index cd59d907..e33770d6 100644 --- a/lib/board/board.dart +++ b/lib/board/board.dart @@ -9,6 +9,7 @@ import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/board_geometry.dart'; import 'package:wqhub/board/coordinate_style.dart'; import 'package:wqhub/board/positioned_point.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/wq/wq.dart' as wq; class Board extends StatefulWidget with BoardGeometry { @@ -290,7 +291,11 @@ class _BoardState extends State { } } - bool boardIsLarge() => widget.settings.visibleSize > 13; + bool boardIsLarge() { + final int confirmMoveboardSize = context.settings.confirmMovesBoardSize; + final int currentBoardSize = widget.settings.visibleSize; + return confirmMoveboardSize <= currentBoardSize; + } void _onPointerHover(PointerHoverEvent event) { final p = widget.offsetPoint(event.localPosition); diff --git a/lib/board/board_lines.dart b/lib/board/board_lines.dart index 535a8e3c..f5f038e3 100644 --- a/lib/board/board_lines.dart +++ b/lib/board/board_lines.dart @@ -48,15 +48,15 @@ class BoardLines extends CustomPainter with BoardGeometry { final boardLineWidth = pointSize / 48; final linePaint = Paint() ..style = PaintingStyle.stroke - ..color = Colors.black + ..color = settings.theme.lineColor ..strokeWidth = boardLineWidth; final thickLinePaint = Paint() ..style = PaintingStyle.stroke - ..color = Colors.black + ..color = settings.theme.lineColor ..strokeWidth = 2 * boardLineWidth; final starPointPaint = Paint() ..style = PaintingStyle.fill - ..color = Colors.black; + ..color = settings.theme.lineColor; final (r0, c0) = settings.topLeft; final (r1, c1) = settings.bottomRight; for (final (r, c) in _starPoints[settings.size] ?? []) { diff --git a/lib/board/board_sizes.dart b/lib/board/board_sizes.dart new file mode 100644 index 00000000..837c690c --- /dev/null +++ b/lib/board/board_sizes.dart @@ -0,0 +1,8 @@ +enum BoardSizes { + size_9(9), + size_13(13), + size_19(19); + + final int value; + const BoardSizes(this.value); +} diff --git a/lib/board/board_theme.dart b/lib/board/board_theme.dart index 1ed2d2ed..e7c1ff33 100644 --- a/lib/board/board_theme.dart +++ b/lib/board/board_theme.dart @@ -5,15 +5,19 @@ import 'package:wqhub/board/stone.dart'; @immutable class BoardTheme { final String id; + final String displayName; final BoardBackground background; final Stone blackStone; final Stone whiteStone; + final Color lineColor; const BoardTheme({ required this.id, + required this.displayName, required this.background, required this.blackStone, required this.whiteStone, + required this.lineColor, }); @override @@ -23,9 +27,11 @@ class BoardTheme { return other is BoardTheme && other.id == id && + other.displayName == displayName && other.background == background && other.blackStone == blackStone && - other.whiteStone == whiteStone; + other.whiteStone == whiteStone && + other.lineColor == lineColor; } @override @@ -35,64 +41,195 @@ class BoardTheme { static const plain = BoardTheme( id: 'plain', + displayName: 'Plain', background: SolidColorBoardBackground(color: Color.fromARGB(255, 218, 174, 92)), - blackStone: SolidColorStone(color: Colors.black), - whiteStone: SolidColorStone(color: Colors.white), + blackStone: SolidColorStone(color: Colors.black, border: false), + whiteStone: SolidColorStone(color: Colors.white, border: false), + lineColor: Colors.black, + ); + + static const plainGradient = BoardTheme( + id: 'plain_gradient', + displayName: 'Plain with gradient', + background: + SolidColorBoardBackground(color: Color.fromARGB(255, 218, 174, 92)), + blackStone: SolidColorStone( + gradient: RadialGradient( + center: AlignmentGeometry.xy(-0.5, -0.5), + radius: 0.8, + colors: [ + Color(0xff13251c), + Colors.black, + ], + ), + border: false, + ), + whiteStone: SolidColorStone( + gradient: RadialGradient( + center: AlignmentGeometry.xy(-0.5, -0.5), + radius: 0.8, + colors: [ + Colors.white, + Color.fromARGB(255, 194, 194, 194), + ], + ), + border: false, + ), + lineColor: Colors.black, + ); + + static const book = BoardTheme( + id: 'book', + displayName: 'Book', + background: SolidColorBoardBackground(color: Colors.white), + blackStone: SolidColorStone(color: Colors.black, border: false), + whiteStone: SolidColorStone(color: Colors.white, border: true), + lineColor: Colors.black, ); static const t101weiqi = BoardTheme( id: '101weiqi', + displayName: '101weiqi', background: ImageBoardBackground( image: AssetImage('$_imagesPath/board/101weiqi.png')), blackStone: ImageStone(image: AssetImage('$_imagesPath/stones/101weiqi_b.png')), whiteStone: ImageStone(image: AssetImage('$_imagesPath/stones/101weiqi_w.png')), + lineColor: Colors.black, ); static const fox = BoardTheme( id: 'fox', + displayName: 'Fox', background: ImageBoardBackground(image: AssetImage('$_imagesPath/board/fox.png')), blackStone: ImageStone(image: AssetImage('$_imagesPath/stones/fox_b.png')), whiteStone: ImageStone(image: AssetImage('$_imagesPath/stones/fox_w.png')), + lineColor: Colors.black, ); static const foxOld = BoardTheme( id: 'fox_old', + displayName: 'Fox - legacy', background: ImageBoardBackground( image: AssetImage('$_imagesPath/board/fox_old.png')), blackStone: ImageStone(image: AssetImage('$_imagesPath/stones/fox_b.png')), whiteStone: ImageStone(image: AssetImage('$_imagesPath/stones/fox_w.png')), + lineColor: Colors.black, ); static const foxMobile = BoardTheme( id: 'fox_mobile', + displayName: 'Fox - mobile', background: ImageBoardBackground( image: AssetImage('$_imagesPath/board/fox_new.png')), blackStone: ImageStone(image: AssetImage('$_imagesPath/stones/fox_new_b.png')), whiteStone: ImageStone(image: AssetImage('$_imagesPath/stones/fox_new_w.png')), + lineColor: Colors.black, + ); + + static const badukTV = BoardTheme( + id: 'baduk_tv', + displayName: 'BadukTV', + background: ImageBoardBackground( + image: AssetImage('$_imagesPath/board/baduktv.png')), + blackStone: + ImageStone(image: AssetImage('$_imagesPath/stones/baduktv_b.png')), + whiteStone: + ImageStone(image: AssetImage('$_imagesPath/stones/baduktv_w.png')), + lineColor: Colors.black, ); static const sabaki = BoardTheme( id: 'sabaki', + displayName: 'Sabaki', background: ImageBoardBackground( image: AssetImage('$_imagesPath/board/sabaki.png')), blackStone: ImageStone(image: AssetImage('$_imagesPath/stones/sabaki_b.png')), whiteStone: ImageStone(image: AssetImage('$_imagesPath/stones/sabaki_w.png')), + lineColor: Colors.black, + ); + + static const goldenHane = BoardTheme( + id: 'golden_hane', + displayName: 'Golden Hane by Pumu', + background: + SolidColorBoardBackground(color: Color.fromARGB(255, 185, 133, 45)), + blackStone: + SolidColorStone(color: Color.fromARGB(255, 52, 45, 9), border: false), + whiteStone: SolidColorStone( + color: Color.fromARGB(255, 205, 194, 175), border: true), + lineColor: Color.fromARGB(255, 216, 213, 197), + ); + + static const sepiaSente = BoardTheme( + id: 'sepia_sente', + displayName: 'Sepia Sente by Pumu', + background: + SolidColorBoardBackground(color: Color.fromARGB(255, 118, 114, 107)), + blackStone: + SolidColorStone(color: Color.fromARGB(255, 53, 46, 34), border: false), + whiteStone: SolidColorStone( + color: Color.fromARGB(255, 206, 201, 192), border: true), + lineColor: Color.fromARGB(255, 216, 213, 197), + ); + + static const jumpingMoss = BoardTheme( + id: 'jumping_moss', + displayName: 'Jumping Moss by Pumu', + background: + SolidColorBoardBackground(color: Color.fromARGB(255, 125, 140, 115)), + blackStone: + SolidColorStone(color: Color.fromARGB(255, 39, 47, 34), border: false), + whiteStone: SolidColorStone( + color: Color.fromARGB(255, 225, 222, 203), border: true), + lineColor: Color.fromARGB(255, 185, 183, 157), + ); + + static const jadeMonkey = BoardTheme( + id: 'jade_monkey', + displayName: 'Jade Monkey by Pumu', + background: + SolidColorBoardBackground(color: Color.fromARGB(255, 88, 132, 105)), + blackStone: + SolidColorStone(color: Color.fromARGB(255, 29, 68, 48), border: false), + whiteStone: SolidColorStone( + color: Color.fromARGB(255, 175, 197, 186), border: true), + lineColor: Color.fromARGB(255, 161, 201, 175), + ); + + static const jadeWalrus = BoardTheme( + id: 'jade_walrus', + displayName: 'Jade Walrus by Pumu', + background: + SolidColorBoardBackground(color: Color.fromARGB(255, 88, 132, 105)), + blackStone: + SolidColorStone(color: Color.fromARGB(255, 11, 35, 23), border: false), + whiteStone: SolidColorStone( + color: Color.fromARGB(255, 214, 225, 219), border: true), + lineColor: Color.fromARGB(255, 161, 201, 175), ); static const themes = { 'plain': plain, + 'plain_gradient': plainGradient, + 'book': book, '101weiqi': t101weiqi, 'fox': fox, 'fox_old': foxOld, 'fox_mobile': foxMobile, + 'baduk_tv': badukTV, 'sabaki': sabaki, + 'golden_hane': goldenHane, + 'sepia_sente': sepiaSente, + 'jumping_moss': jumpingMoss, + 'jade_monkey': jadeMonkey, + 'jade_walrus': jadeWalrus, }; } diff --git a/lib/board/stone.dart b/lib/board/stone.dart index 4aa0834c..56b8b006 100644 --- a/lib/board/stone.dart +++ b/lib/board/stone.dart @@ -5,9 +5,12 @@ abstract class Stone extends StatelessWidget { } class SolidColorStone extends Stone { - final Color color; + final Color? color; + final Gradient? gradient; + final bool border; - const SolidColorStone({super.key, required this.color}); + const SolidColorStone( + {super.key, this.color, this.gradient, required this.border}); @override Widget build(BuildContext context) { @@ -15,6 +18,8 @@ class SolidColorStone extends Stone { decoration: BoxDecoration( shape: BoxShape.circle, color: color, + gradient: gradient, + border: border ? Border.all() : null, ), ); } diff --git a/lib/confirm_dialog.dart b/lib/confirm_dialog.dart index 39959bb3..3296a07a 100644 --- a/lib/confirm_dialog.dart +++ b/lib/confirm_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; class ConfirmDialog extends StatelessWidget { final String title; @@ -15,17 +16,18 @@ class ConfirmDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return AlertDialog( title: Text(title), content: Text(content), actions: [ TextButton( onPressed: onNo, - child: const Text('No'), + child: Text(loc.no), ), TextButton( onPressed: onYes, - child: const Text('Yes'), + child: Text(loc.yes), ), ], ); diff --git a/lib/file_picker.dart b/lib/file_picker.dart deleted file mode 100644 index 9cdebdb2..00000000 --- a/lib/file_picker.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'dart:io'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:path/path.dart'; - -class FilePicker extends StatefulWidget { - const FilePicker( - {super.key, - required this.initialDirectory, - required this.title, - this.okText = 'OK', - this.cancelText = 'Cancel'}); - - final Directory initialDirectory; - final String title; - final String okText; - final String cancelText; - - @override - State createState() => _FilePickerState(); -} - -class _FilePickerState extends State { - late Directory currentDir; - late Future> entries; - - @override - void initState() { - super.initState(); - setDir(widget.initialDirectory); - } - - setDir(Directory dir) { - setState(() { - currentDir = dir; - entries = Future(() { - final entries = dir.listSync(); - entries.retainWhere( - (e) => e.statSync().type == FileSystemEntityType.directory); - entries.sortOrdered((e1, e2) => e1.path.compareTo(e2.path)); - return entries; - }); - }); - } - - @override - Widget build(BuildContext context) { - return Card( - child: Column( - children: [ - Text(widget.title, style: TextTheme.of(context).titleLarge), - SizedBox(height: 8), - Text(currentDir.path), - Expanded( - flex: 12, - child: FutureBuilder( - future: entries, - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView( - children: [ - if (currentDir.path != currentDir.parent.path) - ListTile( - leading: const Icon(Icons.folder), - title: const Text('..'), - onTap: () => setDir(currentDir.parent), - ), - ...[ - for (final e in snapshot.data ?? []) - ListTile( - leading: const Icon(Icons.folder), - title: Text(basename(e.path)), - onTap: () { - setDir(Directory(e.path)); - }, - ) - ] - ], - ); - } - // TODO handle future error - return Center(child: CircularProgressIndicator()); - }, - ), - ), - SizedBox(height: 16), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - spacing: 8, - children: [ - ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); - }, - label: Text(widget.cancelText), - icon: const Icon(Icons.cancel), - ), - ElevatedButton.icon( - onPressed: () { - Navigator.pop(context, currentDir); - }, - label: Text(widget.okText), - icon: const Icon(Icons.save), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/game_client/foxwq b/lib/game_client/foxwq new file mode 160000 index 00000000..b3bbcb62 --- /dev/null +++ b/lib/game_client/foxwq @@ -0,0 +1 @@ +Subproject commit b3bbcb6273811284a5f78cea2d92c9cbc19d052b diff --git a/lib/game_client/game_client_list.dart b/lib/game_client/game_client_list.dart new file mode 100644 index 00000000..2f2cb1fa --- /dev/null +++ b/lib/game_client/game_client_list.dart @@ -0,0 +1,12 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; +import 'package:wqhub/game_client/ogs/ogs_game_client.dart'; +import 'package:wqhub/game_client/test_game_client.dart'; + +final gameClients = IList([ + // OGS prefers that developers use the beta server for testing + OGSGameClient( + serverUrl: + kDebugMode ? "https://beta.online-go.com" : "https://online-go.com"), + if (kDebugMode) TestGameClient(), +]); diff --git a/lib/game_client/game_client_list.dart.PROD b/lib/game_client/game_client_list.dart.PROD new file mode 100644 index 00000000..e1e10674 --- /dev/null +++ b/lib/game_client/game_client_list.dart.PROD @@ -0,0 +1,16 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; +import 'package:wqhub/game_client/foxwq/foxwq_game_client.dart'; +import 'package:wqhub/game_client/ogs/ogs_game_client.dart'; +import 'package:wqhub/game_client/test_game_client.dart'; +import 'package:wqhub/game_client/tygem/tygem_game_client.dart'; + +final gameClients = IList([ + FoxwqGameClient(), + TygemGameClient(), + OGSGameClient( + serverUrl: + kDebugMode ? "https://beta.online-go.com" : "https://online-go.com"), + if (kDebugMode) TestGameClient(), +]); + diff --git a/lib/game_client/game_record.dart b/lib/game_client/game_record.dart index 8d82f8f7..0680e74f 100644 --- a/lib/game_client/game_record.dart +++ b/lib/game_client/game_record.dart @@ -17,23 +17,39 @@ class GameRecord { final sgf = Sgf.parse(sgfStr); if (sgf.trees.isEmpty) throw const FormatException('Empty SGF'); final tree = sgf.trees.first; - if (tree.nodes.length < 2) { - throw const FormatException('Not enough SGF nodes'); - } final moves = []; - for (final node in tree.nodes.skip(1)) { + _appendTrunkMoves(tree, moves); + + return GameRecord( + moves: moves, + type: GameRecordType.sgf, + rawData: utf8.encode(sgfStr), + ); + } + + static void _appendTrunkMoves(SgfTree tree, List moves) { + // Process all nodes in the current tree + for (int i = 0; i < tree.nodes.length; i++) { + final node = tree.nodes[i]; + + // Skip the root node (index 0) only in the main tree + // Child trees don't have root nodes with game info + if (i == 0 && moves.isEmpty) continue; + + // Check for black move if (node.containsKey('B')) { moves.add((col: wq.Color.black, p: wq.parseSgfPoint(node['B']!.first))); } else if (node.containsKey('W')) { moves.add((col: wq.Color.white, p: wq.parseSgfPoint(node['W']!.first))); } } - return GameRecord( - moves: moves, - type: GameRecordType.sgf, - rawData: utf8.encode(sgfStr), - ); + + // Continue with the first child (trunk) if it exists + // Ignore other children (variations) + if (tree.children.isNotEmpty) { + _appendTrunkMoves(tree.children.first, moves); + } } factory GameRecord.fromGib(List gibData) { diff --git a/lib/game_client/ogs/README.md b/lib/game_client/ogs/README.md new file mode 100644 index 00000000..ea29ddef --- /dev/null +++ b/lib/game_client/ogs/README.md @@ -0,0 +1,25 @@ +# OGS (Online Go Server) Client + +This directory contains the implementation of a GameClient for Online Go Server (online-go.com). + +## Files + +- `ogs_game_client.dart` - Main client implementation that handles authentication, game creation, and API communication +- `ogs_game.dart` - Game implementation that handles real-time game state through WebSocket connections (not yet implemented) + +## Features + +- Authentication with OGS using username/password +- Game history browsing and sgf download + +## API Endpoints + +- Authentication: `POST /api/v0/login` +- CSRF Token: `GET /api/v1/ui/config` +- Game List: `GET /api/v1/players/{user_id}/games/` +- Game SGF: `GET /api/v1/games/{game_id}/sgf` + +## TODO + +- Implement gameplay +- Implement automatch diff --git a/lib/game_client/ogs/ogs_game.dart b/lib/game_client/ogs/ogs_game.dart new file mode 100644 index 00000000..3cc9ba4a --- /dev/null +++ b/lib/game_client/ogs/ogs_game.dart @@ -0,0 +1,451 @@ +import 'dart:async'; + +import 'package:wqhub/board/board_state.dart'; +import 'package:wqhub/game_client/automatic_counting_info.dart'; +import 'package:wqhub/game_client/counting_result.dart'; +import 'package:wqhub/game_client/game.dart'; +import 'package:wqhub/game_client/game_result.dart'; +import 'package:wqhub/game_client/ogs/ogs_websocket_manager.dart'; +import 'package:wqhub/game_client/time_state.dart'; +import 'package:wqhub/game_client/user_info.dart'; +import 'package:wqhub/wq/grid.dart'; +import 'package:wqhub/wq/rank.dart'; +import 'package:wqhub/wq/wq.dart' as wq; +import 'package:logging/logging.dart'; + +class OGSGame extends Game { + final Logger _logger = Logger('OGSGame'); + final OGSWebSocketManager _webSocketManager; + final String _myUserId; + late final StreamController _moveController; + late final StreamController _countingResultController; + late final Completer _resultCompleter; + StreamSubscription>? _messageSubscription; + + // Track all moves played in the game for board state reconstruction + final List _allMoves = []; + + // Track stone removal proposals from our opponent or server + String _recentlyRemovedStonesString = ''; + + OGSGame({ + required super.id, + required super.boardSize, + required super.rules, + required super.handicap, + required super.komi, + required super.myColor, + required super.timeControl, + required super.previousMoves, + required OGSWebSocketManager webSocketManager, + required String myUserId, + }) : _webSocketManager = webSocketManager, + _myUserId = myUserId { + _logger.info('Initialized OGSGame with id: $id'); + _moveController = StreamController.broadcast(); + _countingResultController = StreamController.broadcast(); + _resultCompleter = Completer(); + + _allMoves.addAll(previousMoves); + + _setupGame(); + } + + @override + Future acceptCountingResult(bool agree) async { + try { + if (agree) { + // end the game with the removed stones our opponent proposed + final stonesString = _recentlyRemovedStonesString; + + _webSocketManager.send('game/removed_stones/accept', { + 'game_id': int.parse(id), + 'stones': stonesString, + 'strict_seki_mode': false, + }); + } else { + // continue the game + _webSocketManager.send('game/removed_stones/reject', { + 'game_id': int.parse(id), + }); + } + } catch (error) { + _logger.warning( + 'Failed to send stone removal ${agree ? "acceptance" : "rejection"} for game $id: $error'); + rethrow; + } + } + + @override + Future agreeToAutomaticCounting(bool agree) => Future.value(); + + @override + Future aiReferee() => Future.value(); + + @override + Future automaticCounting() => + Future.value(AutomaticCountingInfo(timeout: Duration.zero)); + + @override + Stream automaticCountingResponses() => Stream.empty(); + + @override + Stream countingResultResponses() => Stream.empty(); + + @override + Stream countingResults() => _countingResultController.stream; + + @override + Future forceCounting() => Future.value(); + + @override + Future manualCounting() => Future.value(); + + @override + Future move(wq.Move move) { + final sgfMove = move.p.toSgf(); + return _sendMove(sgfMove, move.col); + } + + @override + Future pass() { + // ".." isn't standard SGF format, but it's what OGS expects for a pass + return _sendMove('..', myColor); + } + + Future _sendMove(String sgfMove, wq.Color color) async { + try { + await _webSocketManager.sendAndGetResponse('game/move', { + 'game_id': int.parse(id), + 'move': sgfMove, + }); + + _logger.fine('Move "$sgfMove" for game $id confirmed by server'); + } catch (error) { + _logger.warning('Failed to send move "$sgfMove" for game $id: $error'); + rethrow; + } + } + + @override + Stream moves() => _moveController.stream; + + void _setupGame() { + // Connect to the game via WebSocket + _webSocketManager.joinGame(id); + + // Listen for move events + _messageSubscription = _webSocketManager.messages.listen(_handleMessage); + } + + void _handleMessage(Map message) { + final event = message['event'] as String; + final gamePrefix = 'game/$id/'; + + // Only handle messages for this specific game + if (!event.startsWith(gamePrefix)) { + return; + } + + final suffix = event.substring(gamePrefix.length); + + switch (suffix) { + case 'error': + _handleError(message['data']); + + case 'gamedata': + _handleGameData(message['data'] as Map); + + case 'removed_stones_accepted': + _handleRemovedStonesAccepted(message['data'] as Map); + + case 'move': + _handleMove(message['data'] as Map); + + case 'clock': + _handleClock(message['data'] as Map); + + default: + _logger.fine('Unhandled game event for game $id: $suffix'); + } + } + + void _handleError(dynamic data) { + _logger.warning('Error received for game $id: $data'); + } + + void _handleMove(Map data) { + final gameId = data['game_id'] as int; + final moveNumber = data['move_number'] as int; + final moveData = data['move'] as dynamic; + + _logger.fine( + 'Received move: game_id=$gameId, move_number=$moveNumber, move=$moveData'); + + // Determine the color based on move number (odd = black, even = white) + // TODO: make sure we're accounting for handicap!! + final color = moveNumber % 2 == 1 ? wq.Color.black : wq.Color.white; + + wq.Point point; + + if (moveData is List && moveData.length >= 2) { + // Numeric format: [row, col, time] where -1,-1 is pass + final row = moveData[1] as int; + final col = moveData[0] as int; + point = (row, col); + } else { + _logger.severe('Unknown move format: $moveData'); + return; + } + + if (point == (-1, -1)) { + _moveController.add(null); + return; + } + + final move = (col: color, p: point); + _allMoves.add(move); + _moveController.add(move); + } + + void _handleGameData(Map gameData) { + _logger.fine('Received gamedata for game $id'); + + try { + // Update player information + final players = gameData['players'] as Map?; + if (players != null) { + _updatePlayerInfo(players); + } + + // Update game result if the game has ended + final phase = gameData['phase'] as String?; + if (phase == 'finished' && !_resultCompleter.isCompleted) { + final winner = gameData['winner'] as int?; + final outcome = gameData['outcome'] as String?; + + if (winner != null && outcome != null) { + final gameResult = _parseGameResult(winner, outcome, players); + _resultCompleter.complete(gameResult); + } + } + } catch (error) { + _logger.warning('Error handling gamedata for game $id: $error'); + } + } + + void _updatePlayerInfo(Map players) { + final blackPlayerData = players['black'] as Map?; + final whitePlayerData = players['white'] as Map?; + + if (blackPlayerData != null) { + final blackUser = _parseUserInfo(blackPlayerData); + black.value = blackUser; + } + + if (whitePlayerData != null) { + final whiteUser = _parseUserInfo(whitePlayerData); + white.value = whiteUser; + } + } + + UserInfo _parseUserInfo(Map playerData) { + final username = playerData['username'] as String? ?? ''; + final userId = (playerData['id'] as int?)?.toString() ?? ''; + final rankValue = playerData['rank'] as double? ?? 0.0; + + // Convert OGS rank (floating point) to our Rank enum + final rank = _ogsRankToRank(rankValue); + + return UserInfo( + userId: userId, + username: username, + rank: rank, + online: false, // We don't have online status from gamedata + winCount: 0, // We don't have win/loss counts from gamedata + lossCount: 0, + ); + } + + Rank _ogsRankToRank(double ogsRank) { + if (ogsRank > 900) { + // Professional ranks on OGS are shifted up by 1000 for whatever reason + final proLevel = (ogsRank - 1000 - 36).floor().clamp(1, 10); + return Rank.values[Rank.p1.index + proLevel - 1]; + } else { + final enumIndex = ogsRank.floor().clamp(0, 39); + return Rank.values[enumIndex]; + } + } + + GameResult _parseGameResult( + int winnerId, String outcome, Map? players) { + wq.Color? winner; + + if (players != null) { + final blackPlayer = players['black'] as Map?; + final whitePlayer = players['white'] as Map?; + + final blackId = blackPlayer?['id'] as int?; + final whiteId = whitePlayer?['id'] as int?; + + if (winnerId == blackId) { + winner = wq.Color.black; + } else if (winnerId == whiteId) { + winner = wq.Color.white; + } + } + + // Map OGS outcome to our result format + final result = switch (outcome.toLowerCase()) { + 'resignation' => 'Resignation', + 'timeout' => 'Time', + 'cancellation' => 'Cancellation', + // Score-based results, e.g. "W+5.5" + _ => outcome, // Keep original for score-based results + }; + + return GameResult( + winner: winner, + result: result, + description: outcome, + ); + } + + void _handleRemovedStonesAccepted(Map data) { + _logger.fine('Received removed stones accepted for game $id'); + + try { + // Check if this is just the server telling us about our own acceptance + final playerId = data['player_id'] as int?; + if (playerId?.toString() == _myUserId) { + _logger.fine('Ignoring our own stone removal acceptance'); + return; + } + + _recentlyRemovedStonesString = data['stones'] as String? ?? ''; + + final countingResult = _calculateCountingResultFromOwnership(data); + _countingResultController.add(countingResult); + } catch (error) { + _logger.warning( + 'Error handling removed stones accepted for game $id: $error'); + } + } + + void _handleClock(Map data) { + _logger.fine('Received clock update for game $id'); + + try { + final blackTimeData = data['black_time'] as Map?; + final whiteTimeData = data['white_time'] as Map?; + + if (blackTimeData != null) { + blackTime.value = + (blackTime.value.$1 + 1, _parseOGSTimeData(blackTimeData)); + } + + if (whiteTimeData != null) { + whiteTime.value = + (whiteTime.value.$1 + 1, _parseOGSTimeData(whiteTimeData)); + } + + _logger.fine( + 'Updated clock for game $id: blackTime=${blackTime.value}, whiteTime=${whiteTime.value}'); + } catch (error) { + _logger.warning('Error handling clock update for game $id: $error'); + } + } + + /// Helper function to safely convert dynamic value to double + static Duration _parseSeconds(dynamic value) { + if (value is double) return Duration(milliseconds: (value * 1000).toInt()); + if (value is int) return Duration(seconds: value); + return Duration.zero; + } + + TimeState _parseOGSTimeData(Map timeData) { + final thinkingTime = _parseSeconds(timeData['thinking_time']); + final periods = timeData['periods'] as int? ?? 0; + final periodTime = _parseSeconds(timeData['period_time']); + + return TimeState( + mainTimeLeft: thinkingTime, + periodTimeLeft: periodTime, + periodCount: periods, + ); + } + + /// Calculate territory ownership and score from OGS ownership data + CountingResult _calculateCountingResultFromOwnership( + Map data) { + _logger.fine('Calculating territory from OGS ownership data'); + + final ownershipData = data['ownership'] as List; + + final ownership = generate2D(boardSize, (i, j) { + final value = (ownershipData[i] as List)[j] as int; + return switch (value) { + -1 => wq.Color.white, + 1 => wq.Color.black, + _ => null, + }; + }); + + final territoryCounts = count2D(ownership); + final blackTerritory = territoryCounts[wq.Color.black] ?? 0; + final whiteTerritory = territoryCounts[wq.Color.white] ?? 0; + + // Calculate captures during the game by reconstructing board state + final boardState = BoardState(size: boardSize); + var blackCaptures = 0; + var whiteCaptures = 0; + + for (final move in _allMoves) { + final capturedStones = boardState.move(move); + if (capturedStones != null) { + switch (move.col) { + case wq.Color.black: + blackCaptures += capturedStones.length; + case wq.Color.white: + whiteCaptures += capturedStones.length; + } + } + } + + final blackScore = blackTerritory + whiteCaptures; + final whiteScore = whiteTerritory + blackCaptures + komi; + + final scoreLead = (blackScore - whiteScore).abs(); + final winner = blackScore > whiteScore ? wq.Color.black : wq.Color.white; + + _logger.fine( + 'Score from ownership: Black=$blackScore (territory: $blackTerritory, captures: $whiteCaptures), ' + 'White=$whiteScore (territory: $whiteTerritory, captures: $blackCaptures, komi: $komi)'); + _logger.fine('Winner: $winner, Lead: $scoreLead'); + + return CountingResult( + winner: winner, + scoreLead: scoreLead, + ownership: ownership, + ); + } + + void dispose() { + _messageSubscription?.cancel(); + _webSocketManager.leaveGame(id); + _moveController.close(); + _countingResultController.close(); + + // Complete the result future if not already completed + if (!_resultCompleter.isCompleted) { + _resultCompleter.completeError('Game disposed before completion'); + } + } + + @override + Future resign() => Future.value(); + + @override + Future result() => _resultCompleter.future; +} diff --git a/lib/game_client/ogs/ogs_game_client.dart b/lib/game_client/ogs/ogs_game_client.dart new file mode 100644 index 00000000..d60ef30c --- /dev/null +++ b/lib/game_client/ogs/ogs_game_client.dart @@ -0,0 +1,622 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:wqhub/game_client/automatch_preset.dart'; +import 'package:wqhub/game_client/game.dart'; +import 'package:wqhub/game_client/game_client.dart'; +import 'package:wqhub/game_client/game_record.dart'; +import 'package:wqhub/game_client/game_result.dart'; +import 'package:wqhub/game_client/ogs/ogs_game.dart'; +import 'package:wqhub/game_client/ogs/ogs_websocket_manager.dart'; +import 'package:wqhub/game_client/rules.dart'; +import 'package:wqhub/game_client/server_features.dart'; +import 'package:wqhub/game_client/server_info.dart'; +import 'package:wqhub/game_client/time_control.dart'; +import 'package:wqhub/game_client/user_info.dart'; +import 'package:wqhub/wq/rank.dart'; +import 'package:wqhub/wq/wq.dart' as wq; +import 'package:uuid/uuid.dart'; +import 'package:logging/logging.dart'; + +class OGSGameClient extends GameClient { + final Logger _logger = Logger('OGSGameClient'); + + static const String _userAgent = 'WeiqiHub/1.0'; + + /// OGS default rank difference for automatch requests + /// https://github.com/online-go/online-go.com/blob/b0d661e69cef0ce57c2c5d4e2e04109227ba9a96/src/lib/preferences.ts#L57-L58 + static const int _defaultRankDiff = 3; + + final ValueNotifier _userInfo = ValueNotifier(null); + final ValueNotifier _disconnected = ValueNotifier(DateTime.now()); + + final String serverUrl; + late final OGSWebSocketManager _webSocketManager; + + OGSGameClient({required this.serverUrl}) { + _webSocketManager = OGSWebSocketManager(serverUrl: serverUrl); + + // Listen to WebSocket connection status + _webSocketManager.connected.addListener(() { + if (!_webSocketManager.connected.value) { + _disconnected.value = DateTime.now(); + } + }); + } + + String? _csrfToken; + String? _jwtToken; + + String? _currentAutomatchUuid; + + Completer? _automatchCompleter; + StreamSubscription>? _messageSubscription; + final http.Client _httpClient = http.Client(); + + @override + ServerInfo get serverInfo => ServerInfo( + id: 'ogs', + name: (loc) => loc.ogsName, + nativeName: 'OGS', + description: (loc) => loc.ogsDesc, + homeUrl: serverUrl, + registerUrl: Uri.parse('$serverUrl/register'), + ); + + @override + ServerFeatures get serverFeatures => ServerFeatures( + manualCounting: true, + automaticCounting: true, + aiReferee: false, // OGS's AI referee cannot be called on-demand + aiRefereeMinMoveCount: const IMapConst({}), + forcedCounting: false, // OGS handles counting differently + forcedCountingMinMoveCount: const IMapConst({}), + localTimeControl: true, + ); + + @override + ValueNotifier get userInfo => _userInfo; + + @override + ValueNotifier get disconnected => _disconnected; + + @override + IList get automatchPresets => _createAutomatchPresets(); + + static IList _createAutomatchPresets() { + const boardSizes = [9, 13, 19]; + const speeds = ['blitz', 'rapid', 'live']; + + // Define time controls for each board size and speed combination based on OGS SPEED_OPTIONS + // https://github.com/online-go/online-go.com/blob/4ed60176f8fe21960376b663515f4721dad22ab1/src/views/Play/SPEED_OPTIONS.ts#L40 + final timeControlsBySpeedAndSize = { + '9x9': { + 'blitz': TimeControl( + mainTime: Duration(seconds: 30), + periodCount: 5, + timePerPeriod: Duration(seconds: 10), + ), + 'rapid': TimeControl( + mainTime: Duration(minutes: 2), + periodCount: 5, + timePerPeriod: Duration(seconds: 30), + ), + 'live': TimeControl( + mainTime: Duration(minutes: 5), + periodCount: 5, + timePerPeriod: Duration(seconds: 30), + ), + }, + '13x13': { + 'blitz': TimeControl( + mainTime: Duration(seconds: 30), + periodCount: 5, + timePerPeriod: Duration(seconds: 10), + ), + 'rapid': TimeControl( + mainTime: Duration(minutes: 3), + periodCount: 5, + timePerPeriod: Duration(seconds: 30), + ), + 'live': TimeControl( + mainTime: Duration(minutes: 10), + periodCount: 5, + timePerPeriod: Duration(seconds: 30), + ), + }, + '19x19': { + 'blitz': TimeControl( + mainTime: Duration(seconds: 30), + periodCount: 5, + timePerPeriod: Duration(seconds: 10), + ), + 'rapid': TimeControl( + mainTime: Duration(minutes: 5), + periodCount: 5, + timePerPeriod: Duration(seconds: 30), + ), + 'live': TimeControl( + mainTime: Duration(minutes: 20), + periodCount: 5, + timePerPeriod: Duration(seconds: 30), + ), + }, + }; + + final presets = []; + + for (final boardSize in boardSizes) { + for (final speed in speeds) { + final sizeKey = '${boardSize}x$boardSize'; + final timeControl = timeControlsBySpeedAndSize[sizeKey]![speed]!; + + presets.add(AutomatchPreset( + id: '${boardSize}_${speed}', + boardSize: boardSize, + variant: Variant.standard, + rules: Rules.japanese, // OGS uses Japanese rules for automatch + timeControl: timeControl, + )); + } + } + + return presets.lock; + } + + @override + Future ready() async { + return ReadyInfo(); + } + + @override + Future login(String username, String password) async { + try { + // First, get the CSRF token + final csrfResponse = await _httpClient.get( + Uri.parse('$serverUrl/api/v1/ui/config'), + headers: {'User-Agent': _userAgent}, + ); + + if (csrfResponse.statusCode != 200) { + throw Exception('Failed to get CSRF token'); + } + + final csrfData = jsonDecode(csrfResponse.body); + _csrfToken = csrfData['csrf_token']; + + // Now attempt login + final loginResponse = await _httpClient.post( + Uri.parse('$serverUrl/api/v0/login'), + headers: { + 'Content-Type': 'application/json', + 'User-Agent': _userAgent, + 'X-CSRFToken': _csrfToken!, + }, + body: jsonEncode({ + 'username': username, + 'password': password, + }), + ); + + if (loginResponse.statusCode == 200) { + final loginData = jsonDecode(loginResponse.body); + final userData = loginData['user']; + + // Extract JWT token for WebSocket authentication + _jwtToken = loginData['user_jwt'] ?? ''; + + final user = UserInfo( + userId: userData['id'].toString(), + username: userData['username'], + rank: _ratingToRank(userData['ratings']['overall']['rating']), + online: true, + winCount: 0, + lossCount: 0, + ); + + _userInfo.value = user; + + await _webSocketManager.connect(); + + // Authenticate with WebSocket if JWT token is available + if (_jwtToken != null && _jwtToken!.isNotEmpty) { + await _webSocketManager.authenticate(jwtToken: _jwtToken!); + } else { + throw Exception('No JWT token received'); + } + + return user; + } else { + throw Exception('Login failed: ${loginResponse.statusCode}'); + } + } catch (e) { + throw Exception('Login error: $e'); + } + } + + @override + void logout() { + _csrfToken = null; + _jwtToken = null; + _currentAutomatchUuid = null; + _userInfo.value = null; + _webSocketManager.disconnect(); + } + + @override + Future ongoingGame() async { + try { + // Get the current user ID for the games API endpoint + final userInfo = _userInfo.value; + if (userInfo == null) { + throw Exception('Not logged in'); + } + + // Query: all ongoing games where the user is a player and "time per move" is less than 1 hour + final response = await _httpClient.get( + Uri.parse('$serverUrl/api/v1/players/${userInfo.userId}/games/') + .replace( + queryParameters: { + 'page_size': '10', + 'page': '1', + 'source': 'play', + 'ended__isnull': 'true', + 'time_per_move__lt': '3600', + }, + ), + headers: { + 'User-Agent': _userAgent, + if (_csrfToken != null) 'X-CSRFToken': _csrfToken!, + }, + ); + + if (response.statusCode != 200) { + throw Exception( + 'Failed to fetch ongoing games: ${response.statusCode}'); + } + + final data = jsonDecode(response.body); + final List results = data['results'] ?? []; + + if (results.isEmpty) { + return null; + } + + final gameData = results.first; + final gameId = gameData['id'].toString(); + + return await getGameFromId(gameId); + } catch (e) { + _logger.warning('Failed to get ongoing game: $e'); + return null; + } + } + + /// Creates an OGSGame instance by fetching game data from the OGS API + Future getGameFromId(String gameId) async { + final userInfo = _userInfo.value; + if (userInfo == null) { + throw Exception('Not logged in'); + } + + final response = await _httpClient.get( + Uri.parse('$serverUrl/api/v1/games/$gameId'), + headers: { + 'User-Agent': _userAgent, + if (_csrfToken != null) 'X-CSRFToken': _csrfToken!, + }, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to fetch game details: ${response.statusCode}'); + } + + final gameData = + jsonDecode(response.body)['gamedata'] as Map; + + // Parse game information + final boardSize = gameData['width'] as int? ?? 19; + final handicap = gameData['handicap'] as int? ?? 0; + final komi = (gameData['komi'] as num?)?.toDouble() ?? 6.5; + + // Parse rules + final rulesString = gameData['rules'] as String?; + final rules = switch (rulesString?.toLowerCase()) { + 'chinese' => Rules.chinese, + 'korean' => Rules.korean, + _ => Rules.japanese, + }; + + // Parse time control + final timeControlData = gameData['time_control'] as Map?; + final timeControl = TimeControl( + mainTime: Duration(seconds: timeControlData?['main_time'] as int? ?? 300), + timePerPeriod: + Duration(seconds: timeControlData?['period_time'] as int? ?? 30), + periodCount: timeControlData?['periods'] as int? ?? 5, + ); + + // Determine our color + final players = gameData['players'] as Map?; + final blackPlayerId = players?['black']?['id']?.toString(); + final whitePlayerId = players?['white']?['id']?.toString(); + + wq.Color myColor; + if (userInfo.userId == blackPlayerId) { + myColor = wq.Color.black; + } else if (userInfo.userId == whitePlayerId) { + myColor = wq.Color.white; + } else { + throw Exception('Unable to determine player color for game $gameId'); + } + + final previousMoves = _parseMovesFromGameData(gameData); + + return OGSGame( + id: gameId, + boardSize: boardSize, + rules: rules, + handicap: handicap, + komi: komi, + myColor: myColor, + timeControl: timeControl, + previousMoves: previousMoves, + webSocketManager: _webSocketManager, + myUserId: userInfo.userId, + ); + } + + @override + Future findGame(String presetId) async { + // Parse the preset ID to extract board size and speed (format: "9_blitz", "19_live", etc.) + final parts = presetId.split('_'); + final boardSize = int.parse(parts[0]); + final speed = parts.length >= 2 ? parts[1] : 'rapid'; + + // Generate a UUID for the automatch request and store it for later cancellation + _currentAutomatchUuid = const Uuid().v4(); + _automatchCompleter = Completer(); + + // Set up message listener for automatch responses + _messageSubscription = + _webSocketManager.messages.listen(_handleAutomatchResponse); + + // Create the automatch payload data matching OGS format + final automatchData = { + "uuid": _currentAutomatchUuid!, + "size_speed_options": [ + {"size": "${boardSize}x$boardSize", "speed": speed, "system": "byoyomi"} + ], + "lower_rank_diff": _defaultRankDiff, + "upper_rank_diff": _defaultRankDiff, + "rules": {"condition": "required", "value": "japanese"}, + "handicap": {"condition": "preferred", "value": "enabled"} + }; + + // Send the automatch request via WebSocket + _webSocketManager.send('automatch/find_match', automatchData); + + return _automatchCompleter!.future; + } + + void _handleAutomatchResponse(Map message) async { + // Check if this is an automatch/start message + if (message['event'] == 'automatch/start' && + message['data']['uuid'] == _currentAutomatchUuid) { + final gameId = message['data']['game_id'] as int; + + // Clean up resources + _messageSubscription?.cancel(); + _messageSubscription = null; + _currentAutomatchUuid = null; + + try { + final game = await getGameFromId(gameId.toString()); + + // TODO: there's chance of a race where the automatch may have been created, but + // the user has already cancelled it. We should think about how to + // avoid getting the user in trouble in this scenario. + _automatchCompleter?.complete(game); + _automatchCompleter = null; + } catch (e) { + _logger.warning("Error creating game: $e"); + _automatchCompleter?.completeError(e); + _automatchCompleter = null; + } + } + } + + @override + void stopAutomatch() { + // Send automatch cancel request to OGS with the stored UUID + if (_currentAutomatchUuid != null) { + _webSocketManager + .send('automatch/cancel', {'uuid': _currentAutomatchUuid!}); + _currentAutomatchUuid = null; // Clear the stored UUID after cancellation + } + + // Clean up message subscription and completer + _messageSubscription?.cancel(); + _messageSubscription = null; + _automatchCompleter?.completeError('Automatch cancelled'); + _automatchCompleter = null; + } + + @override + Future> listGames() async { + try { + // Get the current user ID for the games API endpoint + final userInfo = _userInfo.value; + if (userInfo == null) { + throw Exception('Not logged in'); + } + + final response = await _httpClient.get( + Uri.parse('$serverUrl/api/v1/players/${userInfo.userId}/games/') + .replace( + queryParameters: { + 'page_size': '50', + 'page': '1', + 'source': 'play', + 'ended__isnull': 'false', + 'ordering': '-ended', + }, + ), + headers: { + 'User-Agent': _userAgent, + if (_csrfToken != null) 'X-CSRFToken': _csrfToken!, + }, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to fetch games: ${response.statusCode}'); + } + + final data = jsonDecode(response.body); + final List results = data['results'] ?? []; + + return results.map((gameData) { + // Parse basic game info + final gameId = gameData['id'].toString(); + final boardSize = gameData['width'] as int? ?? 19; + + // Parse dates + final endedStr = gameData['ended'] as String?; + final dateTime = + endedStr != null ? DateTime.parse(endedStr) : DateTime.now(); + + // Parse players + final blackPlayerData = gameData['players']['black']; + final whitePlayerData = gameData['players']['white']; + + final blackPlayer = UserInfo( + userId: blackPlayerData['id'].toString(), + username: blackPlayerData['username'] ?? '', + rank: + _ratingToRank(blackPlayerData['ratings']?['overall']?['rating']), + online: false, // Not available in this API + winCount: 0, // Not available in this API + lossCount: 0, // Not available in this API + ); + + final whitePlayer = UserInfo( + userId: whitePlayerData['id'].toString(), + username: whitePlayerData['username'] ?? '', + rank: + _ratingToRank(whitePlayerData['ratings']?['overall']?['rating']), + online: false, // Not available in this API + winCount: 0, // Not available in this API + lossCount: 0, // Not available in this API + ); + + // Parse game result + final outcome = gameData['outcome'] as String? ?? ''; + final blackLost = gameData['black_lost'] as bool? ?? false; + final whiteLost = gameData['white_lost'] as bool? ?? false; + + wq.Color? winner; + if (blackLost && !whiteLost) { + winner = wq.Color.white; + } else if (whiteLost && !blackLost) { + winner = wq.Color.black; + } + // If both lost or neither lost, winner remains null (draw/unknown) + + final result = GameResult( + winner: winner, + result: outcome, + ); + + return GameSummary( + id: gameId, + boardSize: boardSize, + white: whitePlayer, + black: blackPlayer, + dateTime: dateTime, + result: result, + ); + }).toList(); + } catch (e) { + throw Exception('Failed to list games: $e'); + } + } + + @override + Future getGame(String gameId) async { + try { + final response = await _httpClient.get( + Uri.parse('$serverUrl/api/v1/games/$gameId/sgf'), + headers: { + 'User-Agent': _userAgent, + if (_csrfToken != null) 'X-CSRFToken': _csrfToken!, + }, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to fetch SGF: ${response.statusCode}'); + } + + final sgfContent = response.body; + return GameRecord.fromSgf(sgfContent); + } catch (e) { + throw Exception('Failed to get game record: $e'); + } + } + + Rank _ratingToRank(dynamic rating) { + // OGS Glicko2 rating to rank conversion + const minRating = 100; + const maxRating = 6000; + const a = 525; + const c = 23.15; + + if (rating is num) { + final clipped = rating.clamp(minRating, maxRating); + final rankNum = (math.log(clipped / a) * c).round(); + + final clampedRankNum = rankNum.clamp(0, Rank.values.length - 1); + return Rank.values[clampedRankNum]; + } + return Rank.k6; // Default + } + + List _parseMovesFromGameData(Map gameData) { + final moves = []; + + final movesData = gameData['moves'] as List?; + if (movesData == null) { + return moves; + } + + // TODO: Handle handicap games properly - handicap stones are placed first (black), + // then normal alternating play starts with white. This requires careful consideration + // of how OGS stores handicap stones vs regular moves. + + for (int i = 0; i < movesData.length; i++) { + final moveData = movesData[i]; + + final color = wq.Color.values[i % 2]; + + if (moveData is List && moveData.length >= 2) { + // OGS format: [col, row, time] where -1,-1 is pass + final col = moveData[0] as int; + final row = moveData[1] as int; + moves.add((col: color, p: (row, col))); + } + } + + return moves; + } + + void dispose() { + _httpClient.close(); + _userInfo.dispose(); + _disconnected.dispose(); + _webSocketManager.dispose(); + _messageSubscription?.cancel(); + } +} diff --git a/lib/game_client/ogs/ogs_websocket_manager.dart b/lib/game_client/ogs/ogs_websocket_manager.dart new file mode 100644 index 00000000..6d70f07e --- /dev/null +++ b/lib/game_client/ogs/ogs_websocket_manager.dart @@ -0,0 +1,309 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; +import 'package:stream_channel/stream_channel.dart'; + +class OGSWebSocketManager { + final _log = Logger('OGSWebSocketManager'); + + StreamChannel? _channel; + final String serverUrl; + final StreamChannel Function(Uri) createChannel; + String? _deviceId; + + final ValueNotifier _connected = ValueNotifier(false); + final StreamController> _messageController = + StreamController>.broadcast(); + + Timer? _pingTimer; + Timer? _reconnectTimer; + int _reconnectAttempts = 0; + static const int maxReconnectAttempts = 10; + + // For latency tracking + double _latency = 0.0; + double _clockDrift = 0.0; + + // For request/response tracking + int _lastRequestId = 0; + final Map> _pendingRequests = {}; + + OGSWebSocketManager({ + required this.serverUrl, + this.createChannel = WebSocketChannel.connect, + }) { + _generateDeviceId(); + } + + ValueNotifier get connected => _connected; + Stream> get messages => _messageController.stream; + + void _generateDeviceId() { + // Generate a proper UUID for device ID like OGS frontend + const uuid = Uuid(); + _deviceId = uuid.v4(); + } + + Future connect() async { + if (_channel != null) { + await disconnect(); + } + + try { + // Convert HTTP(S) URL to WebSocket URL + final wsUrl = serverUrl + .replaceFirst('https://', 'wss://') + .replaceFirst('http://', 'ws://'); + + _log.fine('Connecting to OGS WebSocket: $wsUrl'); + + _channel = createChannel(Uri.parse(wsUrl)); + + _channel!.stream.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDisconnect, + ); + + _connected.value = true; + _reconnectAttempts = 0; + + // Start ping timer + _startPingTimer(); + + _log.fine('Connected to OGS WebSocket'); + } catch (e) { + _log.warning('Failed to connect to OGS WebSocket: $e'); + _scheduleReconnect(); + } + } + + Future disconnect() async { + _pingTimer?.cancel(); + _reconnectTimer?.cancel(); + + _rejectPendingRequests(); + + if (_channel != null) { + // Check if this is a WebSocketChannel and close with proper code + if (_channel is WebSocketChannel) { + await (_channel as WebSocketChannel).sink.close(status.goingAway); + } else { + // For generic StreamChannel (like in tests), just close normally + await _channel!.sink.close(); + } + _channel = null; + } + + _connected.value = false; + } + + Future authenticate({required String jwtToken}) async { + if (_channel != null && _connected.value) { + final authData = { + 'jwt': jwtToken, + 'device_id': _deviceId, + 'user_agent': 'WeiqiHub/1.0', + 'language': 'en', + 'language_version': '1.0', + 'client_version': '1.0', + }; + + send('authenticate', authData); + _log.fine('Sent authentication message'); + } + } + + /// Send a fire-and-forget message (no response expected) + void send(String command, [Map? data]) { + if (_channel != null && _connected.value) { + // OGS uses JSON array format: [command] or [command, data] + final message = data != null ? [command, data] : [command]; + final messageStr = jsonEncode(message); + _channel!.sink.add(messageStr); + _log.fine('Sent: $messageStr'); + } else { + _log.warning('Cannot send message: WebSocket not connected'); + } + } + + /// Send a message and return a Future that completes when the server responds + Future sendAndGetResponse(String command, + [Map? data]) { + final completer = Completer(); + + if (_channel != null && _connected.value) { + final requestId = ++_lastRequestId; + _pendingRequests[requestId] = completer; + + final message = [command, data, requestId]; + final messageStr = jsonEncode(message); + _channel!.sink.add(messageStr); + _log.fine('Sent with response expected: $messageStr'); + } else { + completer.completeError(StateError('WebSocket not connected')); + } + + return completer.future; + } + + void joinGame(String gameId) { + send('game/connect', {'game_id': int.parse(gameId)}); + // It's a bit absurd that OGS requires joining the chat channel, + // but this does two things (not chat related): + // - It shows the player as "online" (the green dot over the avatar) + // - It makes the server wait for the player to accept the score when the game ends. + send('chat/join', {'channel': 'game-$gameId'}); + _log.fine('Joining game: $gameId'); + } + + void leaveGame(String gameId) { + send('game/disconnect', {'game_id': int.parse(gameId)}); + send('chat/part', {'channel': 'game-$gameId'}); + _log.fine('Leaving game: $gameId'); + } + + void _handleMessage(dynamic message) { + try { + final String messageStr = message.toString(); + _log.fine('Received: $messageStr'); + + // Parse JSON message - OGS uses plain JSON arrays + final data = jsonDecode(messageStr); + + if (data is List && data.isNotEmpty) { + final commandOrId = data[0]; + final payload = data.length > 1 ? data[1] : null; + + // Check if this is a response to a request (first element is numeric request ID) + // This matches the OGS pattern: [requestId, responseData, errorData] + if (commandOrId is int) { + final requestId = commandOrId; + final error = data.length > 2 ? data[2] : null; + + _log.fine('Handling response for request ID: $requestId'); + + // Handle promise-based requests + final completer = _pendingRequests.remove(requestId); + if (completer != null) { + if (error != null) { + completer.completeError(error); + } else { + completer.complete(payload); + } + } else { + _log.warning( + 'Received response for unknown request ID: $requestId'); + } + + return; + } + + // Handle regular events (first element is string command) + // Format: [command, data] + final command = commandOrId as String; + + // Handle special ping/pong messages + if (command == 'net/pong' && payload is Map) { + _handlePong(payload); + return; + } + + // Emit the message to listeners + _messageController.add({ + 'event': command, + 'data': payload, + }); + } + } catch (e) { + _log.warning('Error parsing message \'$message\': $e'); + } + } + + void _handlePong(Map pongData) { + if (pongData.containsKey('client') && pongData.containsKey('server')) { + final now = DateTime.now().millisecondsSinceEpoch; + final clientTime = pongData['client'] as int; + final serverTime = pongData['server'] as int; + + _latency = (now - clientTime).toDouble(); + _clockDrift = now - _latency / 2 - serverTime; + + _log.fine('Latency: ${_latency}ms, Clock drift: ${_clockDrift}ms'); + } + } + + void _handleError(error) { + _log.warning('WebSocket error: $error'); + _connected.value = false; + _scheduleReconnect(); + } + + void _handleDisconnect() { + _log.info('WebSocket disconnected'); + _connected.value = false; + _scheduleReconnect(); + } + + void _startPingTimer() { + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (_channel != null && _connected.value) { + // Send ping in OGS format + final pingData = { + 'client': DateTime.now().millisecondsSinceEpoch, + 'drift': _clockDrift, + 'latency': _latency, + }; + send('net/ping', pingData); + _log.fine('Sent ping'); + } + }); + } + + void _scheduleReconnect() { + if (_reconnectAttempts >= maxReconnectAttempts) { + _log.warning('Max reconnection attempts reached'); + return; + } + + _rejectPendingRequests(); + + _reconnectTimer?.cancel(); + final delay = Duration( + seconds: math.min(30, math.pow(2, _reconnectAttempts).toInt())); + + _log.info( + 'Scheduling reconnect in ${delay.inSeconds} seconds (attempt ${_reconnectAttempts + 1})'); + + _reconnectTimer = Timer(delay, () { + _reconnectAttempts++; + connect(); + }); + } + + void _rejectPendingRequests() { + // Reject all pending promises + for (final completer in _pendingRequests.values) { + if (!completer.isCompleted) { + completer.completeError(StateError('WebSocket connection lost')); + } + } + _pendingRequests.clear(); + } + + void dispose() { + _pingTimer?.cancel(); + _reconnectTimer?.cancel(); + _rejectPendingRequests(); + _messageController.close(); + _connected.dispose(); + disconnect(); + } +} diff --git a/lib/game_client/rules.dart b/lib/game_client/rules.dart index 85efb8ad..4cd3dbfa 100644 --- a/lib/game_client/rules.dart +++ b/lib/game_client/rules.dart @@ -1,12 +1,13 @@ +import 'package:wqhub/l10n/app_localizations.dart'; + enum Rules { chinese, japanese, korean; - @override - String toString() => switch (this) { - Rules.chinese => 'Chinese', - Rules.japanese => 'Japanese', - Rules.korean => 'Korean', + String toLocalizedString(AppLocalizations loc) => switch (this) { + Rules.chinese => loc.rulesChinese, + Rules.japanese => loc.rulesJapanese, + Rules.korean => loc.rulesKorean, }; } diff --git a/lib/game_client/server_info.dart b/lib/game_client/server_info.dart index 329d65f9..763d995f 100644 --- a/lib/game_client/server_info.dart +++ b/lib/game_client/server_info.dart @@ -1,11 +1,12 @@ -import 'package:flutter/widgets.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; + +typedef LocalizedString = String Function(AppLocalizations); -@immutable class ServerInfo { final String id; - final String name; + final LocalizedString name; final String nativeName; - final String description; + final LocalizedString description; final String homeUrl; final Uri? registerUrl; @@ -17,21 +18,4 @@ class ServerInfo { required this.homeUrl, this.registerUrl, }); - - @override - int get hashCode => - Object.hash(id, name, nativeName, description, homeUrl, registerUrl); - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other.runtimeType != runtimeType) return false; - return other is ServerInfo && - other.id == id && - other.name == name && - other.nativeName == nativeName && - other.description == description && - other.homeUrl == homeUrl && - other.registerUrl == registerUrl; - } } diff --git a/lib/game_client/test_game_client.dart b/lib/game_client/test_game_client.dart index 903614de..fdcdb58d 100644 --- a/lib/game_client/test_game_client.dart +++ b/lib/game_client/test_game_client.dart @@ -110,9 +110,10 @@ class TestGameClient extends GameClient { @override ServerInfo get serverInfo => ServerInfo( id: 'test', - name: 'Test Server', + name: (_) => 'Test Server', nativeName: 'Test Server', - description: 'A dummy server to easily test layout changes, etc.', + description: (_) => + 'A dummy server to easily test layout changes, etc.', homeUrl: 'https://weiqihub.com', ); diff --git a/lib/game_client/tygem b/lib/game_client/tygem new file mode 160000 index 00000000..73654e9a --- /dev/null +++ b/lib/game_client/tygem @@ -0,0 +1 @@ +Subproject commit 73654e9a8dae0f55a1663dbca4609e90c72af618 diff --git a/lib/help/collections_help_dialog.dart b/lib/help/collections_help_dialog.dart new file mode 100644 index 00000000..8d8c37e0 --- /dev/null +++ b/lib/help/collections_help_dialog.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; + +class CollectionsHelpDialog extends StatefulWidget { + @override + State createState() => _CollectionsHelpDialogState(); +} + +class _CollectionsHelpDialogState extends State { + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + const Icon(Icons.help), + Text(loc.help), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loc.helpDialogCollections), + SizedBox(height: 8.0), + Container( + color: ColorScheme.of(context).primaryContainer, + width: 100, + child: Icon(Icons.swipe_left), + ), + SizedBox(height: 8.0), + CheckboxListTile( + title: Text(loc.dontShowAgain), + value: !context.settings.showCollectionsHelp, + onChanged: (value) { + context.settings.showCollectionsHelp = !value!; + setState(() {}); + }), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(loc.ok), + ), + ], + ); + } +} diff --git a/lib/help/endgame_exam_help_dialog.dart b/lib/help/endgame_exam_help_dialog.dart new file mode 100644 index 00000000..0bdae4cc --- /dev/null +++ b/lib/help/endgame_exam_help_dialog.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; + +class EndgameExamHelpDialog extends StatefulWidget { + @override + State createState() => _EndgameExamHelpDialogState(); +} + +class _EndgameExamHelpDialogState extends State { + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + const Icon(Icons.help), + Text(loc.help), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loc.helpDialogEndgameExam), + SizedBox(height: 16.0), + CheckboxListTile( + title: Text(loc.dontShowAgain), + value: !context.settings.showEndgameExamHelp, + onChanged: (value) { + context.settings.showEndgameExamHelp = !value!; + setState(() {}); + }), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(loc.ok), + ), + ], + ); + } +} diff --git a/lib/help/grading_exam_help_dialog.dart b/lib/help/grading_exam_help_dialog.dart new file mode 100644 index 00000000..01a9ad30 --- /dev/null +++ b/lib/help/grading_exam_help_dialog.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; + +class GradingExamHelpDialog extends StatefulWidget { + @override + State createState() => _GradingExamHelpDialogState(); +} + +class _GradingExamHelpDialogState extends State { + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + const Icon(Icons.help), + Text(loc.help), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loc.helpDialogGradingExam), + SizedBox(height: 16.0), + CheckboxListTile( + title: Text(loc.dontShowAgain), + value: !context.settings.showGradingExamHelp, + onChanged: (value) { + context.settings.showGradingExamHelp = !value!; + setState(() {}); + }), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(loc.ok), + ), + ], + ); + } +} diff --git a/lib/help/ranked_mode_help_dialog.dart b/lib/help/ranked_mode_help_dialog.dart new file mode 100644 index 00000000..8b5096dd --- /dev/null +++ b/lib/help/ranked_mode_help_dialog.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; +import 'package:wqhub/train/ranked_mode_page.dart'; +import 'package:wqhub/train/task_source/black_to_play_source.dart'; +import 'package:wqhub/train/task_source/ranked_mode_task_source.dart'; + +class RankedModeHelpDialog extends StatefulWidget { + @override + State createState() => _RankedModeHelpDialogState(); +} + +class _RankedModeHelpDialogState extends State { + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + const Icon(Icons.help), + Text(loc.help), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loc.helpDialogRankedMode), + SizedBox(height: 16.0), + CheckboxListTile( + title: Text(loc.dontShowAgain), + value: !context.settings.showRankedModeHelp, + onChanged: (value) { + context.settings.showRankedModeHelp = !value!; + setState(() {}); + }), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + Navigator.pushNamed( + context, + RankedModePage.routeName, + arguments: RankedModeRouteArguments( + taskSource: BlackToPlaySource( + source: RankedModeTaskSource(context.stats.rankedModeRank), + blackToPlay: context.settings.alwaysBlackToPlay, + ), + ), + ); + }, + child: Text(loc.ok), + ), + ], + ); + } +} diff --git a/lib/help/time_frenzy_help_dialog.dart b/lib/help/time_frenzy_help_dialog.dart new file mode 100644 index 00000000..ddfa18ed --- /dev/null +++ b/lib/help/time_frenzy_help_dialog.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; +import 'package:wqhub/train/task_source/black_to_play_source.dart'; +import 'package:wqhub/train/task_source/time_frenzy_task_source.dart'; +import 'package:wqhub/train/time_frenzy_page.dart'; + +class TimeFrenzyHelpDialog extends StatefulWidget { + @override + State createState() => _TimeFrenzyHelpDialogState(); +} + +class _TimeFrenzyHelpDialogState extends State { + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + const Icon(Icons.help), + Text(loc.help), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loc.helpDialogTimeFrenzy), + SizedBox(height: 16.0), + CheckboxListTile( + title: Text(loc.dontShowAgain), + value: !context.settings.showTimeFrenzyHelp, + onChanged: (value) { + context.settings.showTimeFrenzyHelp = !value!; + setState(() {}); + }), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + Navigator.pushNamed( + context, + TimeFrenzyPage.routeName, + arguments: TimeFrenzyRouteArguments( + taskSource: BlackToPlaySource( + source: TimeFrenzyTaskSource(), + blackToPlay: context.settings.alwaysBlackToPlay, + ), + ), + ); + }, + child: Text(loc.ok), + ), + ], + ); + } +} diff --git a/lib/input/duration_form_field.dart b/lib/input/duration_form_field.dart new file mode 100644 index 00000000..63da7599 --- /dev/null +++ b/lib/input/duration_form_field.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/input/int_form_field.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; + +class DurationFormField extends FormField { + DurationFormField({ + super.key, + String? label, + required Duration initialValue, + required FormFieldValidator validator, + Function(Duration)? onChanged, + }) : super( + initialValue: initialValue, + validator: validator, + autovalidateMode: AutovalidateMode.always, + builder: (FormFieldState st) { + final loc = AppLocalizations.of(st.context)!; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + spacing: 8.0, + children: [ + if (label != null) Text(label), + Expanded( + child: IntFormField( + label: loc.minutes, + initialValue: (st.value?.inMinutes ?? 0) % 60, + minValue: 0, + maxValue: 59, + onChanged: (int? minutes) { + final newDuration = Duration( + minutes: minutes!, + seconds: st.value!.inSeconds % 60, + ); + st.didChange(newDuration); + onChanged?.call(newDuration); + }, + ), + ), + Text(':', style: TextTheme.of(st.context).headlineLarge), + Expanded( + child: IntFormField( + label: loc.seconds, + initialValue: (st.value?.inSeconds ?? 0) % 60, + minValue: 0, + maxValue: 59, + onChanged: (int? seconds) { + final newDuration = Duration( + minutes: st.value!.inMinutes % 60, + seconds: seconds!, + ); + st.didChange(newDuration); + onChanged?.call(newDuration); + }, + ), + ), + ], + ), + if (st.hasError) + Padding( + padding: const EdgeInsets.all(4.0), + child: Text(st.errorText ?? '', + style: + TextStyle(color: ColorScheme.of(st.context).error)), + ), + ], + ); + }, + ); +} diff --git a/lib/input/int_form_field.dart b/lib/input/int_form_field.dart new file mode 100644 index 00000000..400660d2 --- /dev/null +++ b/lib/input/int_form_field.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; + +class IntFormField extends StatelessWidget { + final String label; + final int? initialValue; + final int? minValue; + final int? maxValue; + final Function(int)? onChanged; + + const IntFormField( + {super.key, + required this.label, + this.initialValue, + this.minValue, + this.maxValue, + this.onChanged}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + + (int, String?) _validator(value) { + if (value == null || value.isEmpty) return (0, loc.errCannotBeEmpty); + final count = int.tryParse(value); + if (count == null) return (0, loc.errMustBeInteger); + if (minValue != null && count < minValue!) + return (0, loc.errMustBeAtLeast(minValue!)); + if (maxValue != null && count > maxValue!) + return (0, loc.errMustBeAtMost(maxValue!)); + return (count, null); + } + + return TextFormField( + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: label, + ), + validator: (value) => _validator(value).$2, + initialValue: initialValue?.toString(), + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + autovalidateMode: AutovalidateMode.always, + onChanged: (value) { + final (i, err) = _validator(value); + if (err == null) onChanged?.call(i); + }, + ); + } +} diff --git a/lib/input/rank_range_form_field.dart b/lib/input/rank_range_form_field.dart new file mode 100644 index 00000000..f9ec5467 --- /dev/null +++ b/lib/input/rank_range_form_field.dart @@ -0,0 +1,82 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/train/rank_range.dart'; +import 'package:wqhub/wq/rank.dart'; + +class RankRangeFormField extends FormField { + RankRangeFormField({ + super.key, + required RankRange initialValue, + required FormFieldValidator validator, + Function(RankRange)? onChanged, + }) : super( + initialValue: initialValue, + validator: validator, + autovalidateMode: AutovalidateMode.always, + builder: (FormFieldState st) { + final loc = AppLocalizations.of(st.context)!; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + spacing: 8.0, + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: st.value?.from, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: loc.minRank, + ), + items: [ + for (var i = Rank.k15.index; i <= Rank.d7.index; ++i) + DropdownMenuItem( + value: Rank.values[i], + child: Text(Rank.values[i].toString()), + ), + ], + onChanged: (value) { + final newRange = RankRange( + from: value!, + to: Rank + .values[max(st.value!.to.index, value.index)], + ); + st.didChange(newRange); + onChanged?.call(newRange); + }, + ), + ), + Expanded( + child: DropdownButtonFormField( + initialValue: st.value?.to, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: loc.maxRank, + ), + items: [ + for (var i = (st.value?.from ?? Rank.k15).index; + i <= Rank.d7.index; + ++i) + DropdownMenuItem( + value: Rank.values[i], + child: Text(Rank.values[i].toString()), + ), + ], + onChanged: (value) { + final newRange = RankRange( + from: st.value!.from, + to: value!, + ); + st.didChange(newRange); + onChanged?.call(newRange); + }, + ), + ), + ], + ), + ], + ); + }, + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 00000000..a107e513 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,489 @@ +{ + "about": "About", + "accuracy": "Accuracy", + "aiReferee": "AI referee", + "aiSensei": "AI Sensei", + "alwaysBlackToPlay": "Always black-to-play", + "alwaysBlackToPlayDesc": "Set all tasks as black-to-play to avoid confusion", + "appearance": "Appearance", + "autoCounting": "Auto counting", + "autoMatch": "Auto-Match", + "behaviour": "Behaviour", + "bestResult": "Best result", + "black": "Black", + "board": "Board", + "boardSize": "Board size", + "boardTheme": "Board theme", + "byRank": "By rank", + "cancel": "Cancel", + "captures": "Captures", + "collectStats": "Collect statistics", + "collections": "Collections", + "confirm": "Confirm", + "confirmBoardSize": "Confirm board size", + "confirmBoardSizeDesc": "Boards this size or larger require move confirmation", + "confirmMoves": "Confirm moves", + "confirmMovesDesc": "Double-tap to confirm moves on large boards to avoid misclicks", + "continue_": "Continue", + "copyTaskLink": "Copy task link", + "customExam": "Custom exam", + "dark": "Dark", + "dontShowAgain": "Don't show again", + "download": "Download", + "edgeLine": "Edge line", + "endgameExam": "Endgame exam", + "enterTaskLink": "Enter the task link", + "errCannotBeEmpty": "Cannot be empty", + "errFailedToDownloadGame": "Failed to download game", + "errFailedToLoadGameList": "Failed to load game list. Please try again.", + "errFailedToUploadGameToAISensei": "Failed to upload game to AI Sensei", + "errIncorrectUsernameOrPassword": "Incorrect username or password", + "errMustBeAtLeast": "Must be at least {n}", + "@errMustBeAtLeast": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeAtMost": "Must be at most {n}", + "@errMustBeAtMost": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeInteger": "Must be an integer", + "exit": "Exit", + "exitTryMode": "Exit try mode", + "find": "Find", + "findTask": "Find task", + "forceCounting": "Force counting", + "foxwqDesc": "The most popular server in China and the world.", + "foxwqName": "Fox Weiqi", + "gameInfo": "Game info", + "gameRecord": "Game record", + "gradingExam": "Grading exam", + "handicap": "Handicap", + "help": "Help", + "helpDialogCollections": "Collections are classic, curated sets of high-quality tasks which hold special value together as a training resource.\n\nThe main goal is to solve a collection with a high success rate. A secondary goal is to solve it as fast as possible.\n\nTo start or continue solving a collection, slide left on the collection tile while in portrait mode or click the Start/Continue buttons while in landscape mode.", + "helpDialogEndgameExam": "- Endgame exams are sets of 10 endgame tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.", + "helpDialogGradingExam": "- Grading exams are sets of 10 tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.", + "helpDialogRankedMode": "- Solve tasks without a time limit.\n\n- Task difficulty increases according to how fast you solve them.\n\n- Focus on solving correctly and reach the highest rank possible.", + "helpDialogTimeFrenzy": "- You have 3 minutes to solve as many tasks as possible.\n\n- Tasks get increasingly difficult as you solve them.\n\n- If you make 3 mistakes, you are out.", + "home": "Home", + "komi": "Komi", + "language": "Language", + "leave": "Leave", + "light": "Light", + "login": "Login", + "logout": "Logout", + "long": "Long", + "mMinutes": "{m}min", + "@mMinutes": { + "placeholders": { + "m": { + "type": "int" + } + } + }, + "maxNumberOfMistakes": "Maximum number of mistakes", + "maxRank": "Max rank", + "medium": "Medium", + "minRank": "Min rank", + "minutes": "Minutes", + "month": "Month", + "msgCannotUseAIRefereeYet": "AI referee cannot be used yet", + "msgCannotUseForcedCountingYet": "Forced counting cannot be used yet", + "msgConfirmDeleteCollectionProgress": "Are you sure that you want to delete the previous attempt?", + "msgConfirmResignation": "Are you sure that you want to resign?", + "msgConfirmStopEvent": "Are you sure that you want to stop the {event}?", + "@msgConfirmStopEvent": { + "placeholders": { + "event": { + "type": "String" + } + } + }, + "msgDownloadingGame": "Downloading game", + "msgGameSavedTo": "Game saved to {path}", + "@msgGameSavedTo": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "msgPleaseWaitForYourTurn": "Please, wait for your turn", + "msgSearchingForGame": "Searching for a game...", + "msgTaskLinkCopied": "Task link copied.", + "msgWaitingForOpponentsDecision": "Waiting for your opponent's decision...", + "msgYouCannotPass": "You cannot pass", + "msgYourOpponentDisagreesWithCountingResult": "Your opponent disagrees with the counting result", + "msgYourOpponentRefusesToCount": "Your opponent refuses to count", + "msgYourOpponentRequestsAutomaticCounting": "Your opponent requests automatic counting. Do you agree?", + "myGames": "My games", + "myMistakes": "My mistakes", + "nTasks": "{count, plural, =0{No tasks} =1{1 task} other{{count} tasks}}", + "@nTasks": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "nTasksAvailable": "{count, plural, =0{No tasks available} =1{1 task available} other{{count} tasks available}}", + "@nTasksAvailable": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "newBestResult": "New best!", + "no": "No", + "none": "None", + "numberOfTasks": "Number of tasks", + "nxnBoardSize": "{n}×{n}", + "@nxnBoardSize": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "ogsDesc": "The premier online Go platform with tournaments, AI analysis, and a vibrant community.", + "ogsName": "Online Go Server", + "ok": "OK", + "pass": "Pass", + "password": "Password", + "play": "Play", + "promotionRequirements": "Promotion requirements", + "pxsByoyomi": "{p}×{s}s", + "@pxsByoyomi": { + "placeholders": { + "p": { + "type": "int" + }, + "s": { + "type": "int" + } + } + }, + "rank": "Rank", + "rankedMode": "Ranked mode", + "recentRecord": "Recent record", + "register": "Register", + "resign": "Resign", + "responseDelay": "Response delay", + "responseDelayDesc": "Duration of the delay before the response appears while solving tasks", + "responseDelayLong": "Long", + "responseDelayMedium": "Medium", + "responseDelayNone": "None", + "responseDelayShort": "Short", + "result": "Result", + "rules": "Rules", + "rulesChinese": "Chinese", + "rulesJapanese": "Japanese", + "rulesKorean": "Korean", + "save": "Save", + "saveSGF": "Save SGF", + "seconds": "Seconds", + "settings": "Settings", + "short": "Short", + "showCoordinates": "Show coordinates", + "simple": "Simple", + "sortModeDifficult": "Difficult", + "sortModeRecent": "Recent", + "sound": "Sound", + "start": "Start", + "statistics": "Statistics", + "statsDateColumn": "Date", + "statsDurationColumn": "Time", + "statsTimeColumn": "Time", + "stoneShadows": "Stone shadows", + "stones": "Stones", + "subtopic": "Subtopic", + "system": "System", + "task": "Task", + "taskCorrect": "Correct", + "taskNext": "Next", + "taskRedo": "Redo", + "taskSource": "Task source", + "taskSourceFromMyMistakes": "From my mistakes", + "taskSourceFromTaskTopic": "From task topic", + "taskSourceFromTaskTypes": "From task types", + "taskTag_afterJoseki": "After joseki", + "taskTag_aiOpening": "AI opening", + "taskTag_aiVariations": "AI variations", + "taskTag_attack": "Attack", + "taskTag_attackAndDefenseInKo": "Attack and defense in a ko", + "taskTag_attackAndDefenseOfCuts": "Attack and defense of cuts", + "taskTag_attackAndDefenseOfInvadingStones": "Attack and defense of invading stones", + "taskTag_avoidKo": "Avoid ko", + "taskTag_avoidMakingDeadShape": "Avoid making dead shape", + "taskTag_avoidTrap": "Avoid trap", + "taskTag_basicEndgame": "Endgame: basic", + "taskTag_basicLifeAndDeath": "Life \u0026 death: basic", + "taskTag_basicMoves": "Basic moves", + "taskTag_basicTesuji": "Tesuji", + "taskTag_beginner": "Beginner", + "taskTag_bend": "Bend", + "taskTag_bentFour": "Bent four", + "taskTag_bentFourInTheCorner": "Bent four in the corner", + "taskTag_bentThree": "Bent three", + "taskTag_bigEyeLiberties": "Big eye's liberties", + "taskTag_bigEyeVsSmallEye": "Big eye vs small eye", + "taskTag_bigPoints": "Big points", + "taskTag_blindSpot": "Blind spot", + "taskTag_breakEye": "Break eye", + "taskTag_breakEyeInOneStep": "Break eye in one step", + "taskTag_breakEyeInSente": "Break eye in sente", + "taskTag_breakOut": "Break out", + "taskTag_breakPoints": "Break points", + "taskTag_breakShape": "Break shape", + "taskTag_bridgeUnder": "Bridge under", + "taskTag_brilliantSequence": "Brilliant sequence", + "taskTag_bulkyFive": "Bulky five", + "taskTag_bump": "Bump", + "taskTag_captureBySnapback": "Capture by snapback", + "taskTag_captureInLadder": "Capture in ladder", + "taskTag_captureInOneMove": "Capture in one move", + "taskTag_captureOnTheSide": "Capture on the side", + "taskTag_captureToLive": "Capture to live", + "taskTag_captureTwoRecaptureOne": "Capture two, recapture one", + "taskTag_capturingRace": "Capturing race", + "taskTag_capturingTechniques": "Capturing techniques", + "taskTag_carpentersSquareAndSimilar": "Carpenter's square and similar", + "taskTag_chooseTheFight": "Choose the fight", + "taskTag_clamp": "Clamp", + "taskTag_clampCapture": "Clamp capture", + "taskTag_closeInCapture": "Closing-in capture", + "taskTag_combination": "Combination", + "taskTag_commonLifeAndDeath": "Life \u0026 death: common shapes", + "taskTag_compareSize": "Compare size", + "taskTag_compareValue": "Compare value", + "taskTag_completeKoToSecureEndgameAdvantage": "Complete ko to secure endgame advantage", + "taskTag_compositeProblems": "Composite tasks", + "taskTag_comprehensiveTasks": "Comprehensive tasks", + "taskTag_connect": "Connect", + "taskTag_connectAndDie": "Connect and die", + "taskTag_connectInOneMove": "Connect in one move", + "taskTag_contactFightTesuji": "Contact fight tesuji", + "taskTag_contactPlay": "Contact play", + "taskTag_corner": "Corner", + "taskTag_cornerIsGoldSideIsSilverCenterIsGrass": "Corner is gold, side is silver, center is grass", + "taskTag_counter": "Counter", + "taskTag_counterAttack": "Counter-attack", + "taskTag_cranesNest": "Crane's nest", + "taskTag_crawl": "Crawl", + "taskTag_createShortageOfLiberties": "Create shortage of liberties", + "taskTag_crossedFive": "Crossed five", + "taskTag_cut": "Cut", + "taskTag_cut2": "Cut", + "taskTag_cutAcross": "Cut across", + "taskTag_defendFromInvasion": "Defend from invasion", + "taskTag_defendPoints": "Defend points", + "taskTag_defendWeakPoint": "Defend weak point", + "taskTag_descent": "Descent", + "taskTag_diagonal": "Diagonal", + "taskTag_directionOfCapture": "Direction of capture", + "taskTag_directionOfEscape": "Direction of escape", + "taskTag_directionOfPlay": "Direction of play", + "taskTag_doNotUnderestimateOpponent": "Do not underestimate opponent", + "taskTag_doubleAtari": "Double atari", + "taskTag_doubleCapture": "Double capture", + "taskTag_doubleKo": "Double ko", + "taskTag_doubleSenteEndgame": "Double sente endgame", + "taskTag_doubleSnapback": "Double snapback", + "taskTag_endgame": "Endgame: general", + "taskTag_endgameFundamentals": "Endgame fundamentals", + "taskTag_endgameIn5x5": "Endgame on 5x5", + "taskTag_endgameOn4x4": "Endgame on 4x4", + "taskTag_endgameTesuji": "Endgame tesuji", + "taskTag_engulfingAtari": "Engulfing atari", + "taskTag_escape": "Escape", + "taskTag_escapeInOneMove": "Escape in one move", + "taskTag_exploitShapeWeakness": "Exploit shape weakness", + "taskTag_eyeVsNoEye": "Eye vs no-eye", + "taskTag_fillNeutralPoints": "Fill neutral points", + "taskTag_findTheRoot": "Find the root", + "taskTag_firstLineBrilliantMove": "First line brilliant move", + "taskTag_flowerSix": "Flower six", + "taskTag_goldenChickenStandingOnOneLeg": "Golden rooster standing on one leg", + "taskTag_groupLiberties": "Group liberties", + "taskTag_groupsBase": "Group's base", + "taskTag_hane": "Hane", + "taskTag_increaseEyeSpace": "Increase eye space", + "taskTag_increaseLiberties": "Increase liberties", + "taskTag_indirectAttack": "Indirect attack", + "taskTag_influenceKeyPoints": "Influence key points", + "taskTag_insideKill": "Inside kill", + "taskTag_insideMoves": "Inside moves", + "taskTag_interestingTasks": "Interesting tasks", + "taskTag_internalLibertyShortage": "Internal liberty shortage", + "taskTag_invadingTechnique": "Invading technique", + "taskTag_invasion": "Invasion", + "taskTag_jGroupAndSimilar": "J-group and similar", + "taskTag_josekiFundamentals": "Joseki fundamentals", + "taskTag_jump": "Jump", + "taskTag_keepSente": "Keep sente", + "taskTag_killAfterCapture": "Kill after capture", + "taskTag_killByEyePointPlacement": "Kill by eye point placement", + "taskTag_knightsMove": "Knight's move", + "taskTag_ko": "Ko", + "taskTag_kosumiWedge": "Kosumi wedge", + "taskTag_largeKnightsMove": "Large knight move", + "taskTag_largeMoyoFight": "Large moyo fight", + "taskTag_lifeAndDeath": "Life \u0026 death: general", + "taskTag_lifeAndDeathOn4x4": "Life and death on 4x4", + "taskTag_lookForLeverage": "Look for leverage", + "taskTag_looseLadder": "Loose ladder", + "taskTag_lovesickCut": "Lovesick cut", + "taskTag_makeEye": "Make eye", + "taskTag_makeEyeInOneStep": "Make eye in one step", + "taskTag_makeEyeInSente": "Make eye in sente", + "taskTag_makeKo": "Make ko", + "taskTag_makeShape": "Make shape", + "taskTag_middlegame": "Middlegame", + "taskTag_monkeyClimbingMountain": "Monkey climbing the mountain", + "taskTag_mouseStealingOil": "Mouse stealing oil", + "taskTag_moveOut": "Move out", + "taskTag_moveTowardsEmptySpace": "Move towards empty space", + "taskTag_multipleBrilliantMoves": "Multiple brilliant moves", + "taskTag_net": "Net", + "taskTag_netCapture": "Net capture", + "taskTag_observeSubtleDifference": "Observe subtle difference", + "taskTag_occupyEncloseAndApproachCorner": "Occupy, enclose and approach corners", + "taskTag_oneStoneTwoPurposes": "One stone, two purposes", + "taskTag_opening": "Opening", + "taskTag_openingChoice": "Opening choice", + "taskTag_openingFundamentals": "Opening fundamentals", + "taskTag_orderOfEndgameMoves": "Order of endgame moves", + "taskTag_orderOfMoves": "Order of moves", + "taskTag_orderOfMovesInKo": "Order of moves in a ko", + "taskTag_orioleCapturesButterfly": "Oriole captures the butterfly", + "taskTag_pincer": "Pincer", + "taskTag_placement": "Placement", + "taskTag_plunderingTechnique": "Plundering technique", + "taskTag_preventBambooJoint": "Prevent the bamboo joint", + "taskTag_preventBridgingUnder": "Prevent bridging under", + "taskTag_preventOpponentFromApproaching": "Prevent opponent from approaching", + "taskTag_probe": "Probe", + "taskTag_profitInSente": "Profit in sente", + "taskTag_profitUsingLifeAndDeath": "Profit using life and death", + "taskTag_push": "Push", + "taskTag_pyramidFour": "Pyramid four", + "taskTag_realEyeAndFalseEye": "Real eye vs false eye", + "taskTag_rectangularSix": "Rectangular six", + "taskTag_reduceEyeSpace": "Reduce eye space", + "taskTag_reduceLiberties": "Reduce liberties", + "taskTag_reduction": "Reduction", + "taskTag_runWeakGroup": "Run weak group", + "taskTag_sabakiAndUtilizingInfluence": "Sabaki and utilizing influence", + "taskTag_sacrifice": "Sacrifice", + "taskTag_sacrificeAndSqueeze": "Sacrifice and squeeze", + "taskTag_sealIn": "Seal in", + "taskTag_secondLine": "Second line", + "taskTag_seizeTheOpportunity": "Seize the opportunity", + "taskTag_seki": "Seki", + "taskTag_senteAndGote": "Sente and gote", + "taskTag_settleShape": "Settle shape", + "taskTag_settleShapeInSente": "Settle shape in sente", + "taskTag_shape": "Shape", + "taskTag_shapesVitalPoint": "Shape's vital point", + "taskTag_side": "Side", + "taskTag_smallBoardEndgame": "Small board endgame", + "taskTag_snapback": "Snapback", + "taskTag_solidConnection": "Solid connection", + "taskTag_solidExtension": "Solid extension", + "taskTag_splitInOneMove": "Split in one move", + "taskTag_splittingMove": "Splitting move", + "taskTag_squareFour": "Square four", + "taskTag_squeeze": "Squeeze", + "taskTag_standardCapturingRaces": "Standard capturing races", + "taskTag_standardCornerAndSideEndgame": "Standard corner and side endgame", + "taskTag_straightFour": "Straight four", + "taskTag_straightThree": "Straight three", + "taskTag_surroundTerritory": "Surround territory", + "taskTag_symmetricShape": "Symmetric shape", + "taskTag_techniqueForReinforcingGroups": "Technique for reinforcing groups", + "taskTag_techniqueForSecuringTerritory": "Technique for securing territory", + "taskTag_textbookTasks": "Textbook tasks", + "taskTag_thirdAndFourthLine": "Third and fourth line", + "taskTag_threeEyesTwoActions": "Three eyes, two actions", + "taskTag_threeSpaceExtensionFromTwoStones": "Three-space extension from two stones", + "taskTag_throwIn": "Throw-in", + "taskTag_tigersMouth": "Tiger's mouth", + "taskTag_tombstoneSqueeze": "Tombstone squeeze", + "taskTag_tripodGroupWithExtraLegAndSimilar": "Tripod group with extra leg and similar", + "taskTag_twoHaneGainOneLiberty": "Double hane grows one liberty", + "taskTag_twoHeadedDragon": "Two-headed dragon", + "taskTag_twoSpaceExtension": "Two-space extension", + "taskTag_typesOfKo": "Types of ko", + "taskTag_underTheStones": "Under the stones", + "taskTag_underneathAttachment": "Underneath attachment", + "taskTag_urgentPointOfAFight": "Urgent point of a fight", + "taskTag_urgentPoints": "Urgent points", + "taskTag_useConnectAndDie": "Use connect and die", + "taskTag_useCornerSpecialProperties": "Use corner special properties", + "taskTag_useDescentToFirstLine": "Use descent to first line", + "taskTag_useInfluence": "Use influence", + "taskTag_useOpponentsLifeAndDeath": "Use opponent's life and death", + "taskTag_useShortageOfLiberties": "Use shortage of liberties", + "taskTag_useSnapback": "Use snapback", + "taskTag_useSurroundingStones": "Use surrounding stones", + "taskTag_vitalAndUselessStones": "Vital and useless stones", + "taskTag_vitalPointForBothSides": "Vital point for both sides", + "taskTag_vitalPointForCapturingRace": "Vital point for capturing race", + "taskTag_vitalPointForIncreasingLiberties": "Vital point for increasing liberties", + "taskTag_vitalPointForKill": "Vital point for kill", + "taskTag_vitalPointForLife": "Vital point for life", + "taskTag_vitalPointForReducingLiberties": "Vital point for reducing liberties", + "taskTag_wedge": "Wedge", + "taskTag_wedgingCapture": "Wedging capture", + "taskTimeout": "Timeout", + "taskTypeAppreciation": "Appreciation", + "taskTypeCapture": "Capture stones", + "taskTypeCaptureRace": "Capture race", + "taskTypeEndgame": "Endgame", + "taskTypeJoseki": "Joseki", + "taskTypeLifeAndDeath": "Life \u0026 death", + "taskTypeMiddlegame": "Middlegame", + "taskTypeOpening": "Opening", + "taskTypeTesuji": "Tesuji", + "taskTypeTheory": "Theory", + "taskWrong": "Wrong", + "tasksSolved": "Tasks solved", + "test": "Test", + "theme": "Theme", + "thick": "Thick", + "timeFrenzy": "Time frenzy", + "timePerTask": "Time per task", + "today": "Today", + "tooltipAnalyzeWithAISensei": "Analyze with AI Sensei", + "tooltipDownloadGame": "Download game", + "topic": "Topic", + "topicExam": "Topic exam", + "topics": "Topics", + "train": "Train", + "trainingAvgTimePerTask": "Avg time per task", + "trainingFailed": "Failed", + "trainingMistakes": "Mistakes", + "trainingPassed": "Passed", + "trainingTotalTime": "Total time", + "tryCustomMoves": "Try custom moves", + "tygemDesc": "The most popular server in Korea and one of the most popular in the world.", + "tygemName": "Tygem Baduk", + "type": "Type", + "ui": "UI", + "userInfo": "User info", + "username": "Username", + "voice": "Voice", + "week": "Week", + "white": "White", + "yes": "Yes" +} \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb new file mode 100644 index 00000000..dfe08f6a --- /dev/null +++ b/lib/l10n/app_es.arb @@ -0,0 +1,489 @@ +{ + "about": "Acerca de WeiqiHub", + "accuracy": "Precisión", + "aiReferee": "Árbitro IA", + "aiSensei": "AI Sensei", + "alwaysBlackToPlay": "Siempre juegan las negras", + "alwaysBlackToPlayDesc": "Hace que en todos los problemas jueguen las negras para evitar confusión", + "appearance": "Apariencia", + "autoCounting": "Conteo automático", + "autoMatch": "Buscar partida", + "behaviour": "Comportamiento", + "bestResult": "Mejor resultado", + "black": "Negras", + "board": "Tablero", + "boardSize": "Tamaño del tablero", + "boardTheme": "Tema del tablero", + "byRank": "Por rango", + "cancel": "Cancelar", + "captures": "Capturas", + "collectStats": "Añadir a las estadísticas", + "collections": "Colecciones", + "confirm": "Confirmar", + "confirmBoardSize": "Tamaño de confirmación", + "confirmBoardSizeDesc": "Tableros de este tamaño o mayores requieren confirmar jugadas", + "confirmMoves": "Confirmar jugada", + "confirmMovesDesc": "Doble tap para confirmar jugadas en tableros grandes para evitar accidentes", + "continue_": "Continuar", + "copyTaskLink": "Copiar enlace al problema", + "customExam": "Examen personalizado", + "dark": "Oscuro", + "dontShowAgain": "No volver a mostrar", + "download": "Descargar", + "edgeLine": "Línea de borde", + "endgameExam": "Examen de finales", + "enterTaskLink": "Introduce el enlace al problema", + "errCannotBeEmpty": "No puede estar vacío", + "errFailedToDownloadGame": "Error descargando partida.", + "errFailedToLoadGameList": "Error cargando lista de partidas. Por favor, prueba de nuevo.", + "errFailedToUploadGameToAISensei": "Error enviando partida a AI Sensei", + "errIncorrectUsernameOrPassword": "Nombre de usuario o contraseña incorrectos", + "errMustBeAtLeast": "No puede ser menor que {n}", + "@errMustBeAtLeast": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeAtMost": "No puede ser mayor que {n}", + "@errMustBeAtMost": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeInteger": "Debe ser un número entero", + "exit": "Salir", + "exitTryMode": "Regresar", + "find": "Buscar", + "findTask": "Buscar problema", + "forceCounting": "Forzar conteo", + "foxwqDesc": "El servidor más popular de China y el mundo.", + "foxwqName": "Fox Weiqi", + "gameInfo": "Información de la partida", + "gameRecord": "Partida", + "gradingExam": "Examen de rango", + "handicap": "Handicap", + "help": "Ayuda", + "helpDialogCollections": "Las colecciones son libros clásicos de problemas de alta calidad que tienen un valor especial para el entrenamiento.\n\nEl objectivo principal es resolver una colección con un alto porcentaje de éxito. El objetivo secundario es resolver una colección lo más rápido posible.\n\nPara comenzar o continuar resolviendo una colección, desliza la colección hacia la izquierda en modo retrato, o haz click en el botón Comenzar/Continuar en modo paisaje.", + "helpDialogEndgameExam": "- El examen de finales consiste de 10 problemas de finales y tienes 45 segundos para cada problema.\n\n- Apruebas el examen si resuelves 8 o más problemas correctamente (porcentaje de éxito de 80%).\n\n- Si apruebas el examen de un rango, desbloqueas el examen del rango siguiente.", + "helpDialogGradingExam": "- El examen de rango consiste de 10 problemas y tienes 45 segundos para cada problema.\n\n- Apruebas el examen si resuelves 8 o más problemas correctamente (porcentaje de éxito de 80%).\n\n- Si apruebas el examen de un rango, desbloqueas el examen del rango siguiente.", + "helpDialogRankedMode": "- Resuelve problemas sin límite de tiempo.\n\n- La dificultad de los problemas aumenta de acuerdo a qué tan rápido los resuelves.\n\n- Concéntrate en resolver problemas correctamente y alcanzar el rango más alto posible.", + "helpDialogTimeFrenzy": "- Tienes 3 minutos para resolver tantos problemas como sea posible.\n\n- La dificultad de los problemas aumenta a medida que los resuelves.\n\n- Si fallas 3 problemas, el contrarreloj termina.", + "home": "Inicio", + "komi": "Komi", + "language": "Idioma", + "leave": "Salir", + "light": "Claro", + "login": "Entrar", + "logout": "Salir", + "long": "Larga", + "mMinutes": "{m}min", + "@mMinutes": { + "placeholders": { + "m": { + "type": "int" + } + } + }, + "maxNumberOfMistakes": "Número máximo de errores", + "maxRank": "Rango máximo", + "medium": "Media", + "minRank": "Rango mínimo", + "minutes": "Minutos", + "month": "Mes", + "msgCannotUseAIRefereeYet": "El árbitro IA no está disponible todavía", + "msgCannotUseForcedCountingYet": "No es posible forzar el conteo automático todavía", + "msgConfirmDeleteCollectionProgress": "¿Estás seguro(a) de que quieres abandonar el intento anterior?", + "msgConfirmResignation": "¿Estás seguro(a) de que quieres abandonar?", + "msgConfirmStopEvent": "¿Estás seguro(a) de que quieres abandonar el {event}?", + "@msgConfirmStopEvent": { + "placeholders": { + "event": { + "type": "String" + } + } + }, + "msgDownloadingGame": "Descargando partida", + "msgGameSavedTo": "Partida guardada en {path}", + "@msgGameSavedTo": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "msgPleaseWaitForYourTurn": "Por favor, espera tu turno", + "msgSearchingForGame": "Buscando partida...", + "msgTaskLinkCopied": "Enlace al problema copiado.", + "msgWaitingForOpponentsDecision": "Esperando la decisión de tu oponente...", + "msgYouCannotPass": "No puedes pasar", + "msgYourOpponentDisagreesWithCountingResult": "Tu oponente no está de acuerdo con el resultado del conteo", + "msgYourOpponentRefusesToCount": "Tu oponente no acepta el conteo automático.", + "msgYourOpponentRequestsAutomaticCounting": "Tu oponente pide conteo automático. ¿Aceptas?", + "myGames": "Mis partidas", + "myMistakes": "Mis errores", + "nTasks": "{count, plural, =0{No hay problemas} =1{1 problema} other{{count} problemas}}", + "@nTasks": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "nTasksAvailable": "{count, plural, =0{No hay problems disponibles} =1{1 problema disponible} other{{count} problemas disponibles}}", + "@nTasksAvailable": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "newBestResult": "Nuevo record!", + "no": "No", + "none": "Ninguna", + "numberOfTasks": "Número de problemas", + "nxnBoardSize": "{n}×{n}", + "@nxnBoardSize": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "ogsDesc": "La principal plataforma en línea de Go, con torneos, análisis con IA y una vibrante comunidad.", + "ogsName": "Online Go Server", + "ok": "OK", + "pass": "Pasar", + "password": "Contraseña", + "play": "Jugar", + "promotionRequirements": "Requisitos de promoción", + "pxsByoyomi": "{p}×{s}s", + "@pxsByoyomi": { + "placeholders": { + "p": { + "type": "int" + }, + "s": { + "type": "int" + } + } + }, + "rank": "Rango", + "rankedMode": "Clasificatorio", + "recentRecord": "Resultados recientes", + "register": "Registrarse", + "resign": "Abandonar", + "responseDelay": "Demora de respuesta", + "responseDelayDesc": "Demora de la respuesta del oponente mientras resuelves problemas", + "responseDelayLong": "Larga", + "responseDelayMedium": "Media", + "responseDelayNone": "Sin demora", + "responseDelayShort": "Corta", + "result": "Resultado", + "rules": "Reglas", + "rulesChinese": "Chinas", + "rulesJapanese": "Japonesas", + "rulesKorean": "Coreanas", + "save": "Guardar", + "saveSGF": "Guardar SGF", + "seconds": "Segundos", + "settings": "Preferencias", + "short": "Corta", + "showCoordinates": "Mostrar coordenadas", + "simple": "Simple", + "sortModeDifficult": "Difíciles", + "sortModeRecent": "Recientes", + "sound": "Sonido", + "start": "Comenzar", + "statistics": "Estadísticas", + "statsDateColumn": "Fecha", + "statsDurationColumn": "Tiempo", + "statsTimeColumn": "Hora", + "stoneShadows": "Sombra de las piedras", + "stones": "Piedras", + "subtopic": "Subtema", + "system": "Sistema", + "task": "Problema", + "taskCorrect": "Correcto", + "taskNext": "Siguiente", + "taskRedo": "Reintentar", + "taskSource": "Origen de problemas", + "taskSourceFromMyMistakes": "Mis errores", + "taskSourceFromTaskTopic": "Por tema", + "taskSourceFromTaskTypes": "Por tipos de problema", + "taskTag_afterJoseki": "Continuaciones de joseki", + "taskTag_aiOpening": "Apertura de IA", + "taskTag_aiVariations": "Variaciones de IA", + "taskTag_attack": "Ataque", + "taskTag_attackAndDefenseInKo": "Ataque y defensa en ko", + "taskTag_attackAndDefenseOfCuts": "Ataque y defensa de cortes", + "taskTag_attackAndDefenseOfInvadingStones": "Ataque y defensa de piedras de invasión", + "taskTag_avoidKo": "Evitar ko", + "taskTag_avoidMakingDeadShape": "Evitar forma muerta", + "taskTag_avoidTrap": "Evitar trampas", + "taskTag_basicEndgame": "Finales: básico", + "taskTag_basicLifeAndDeath": "Vida y muerte: básico", + "taskTag_basicMoves": "Jugadas básicas", + "taskTag_basicTesuji": "Tesuji", + "taskTag_beginner": "Principiante", + "taskTag_bend": "Doblar", + "taskTag_bentFour": "Cuatro dobladas", + "taskTag_bentFourInTheCorner": "Cuatro dobladas en la esquina", + "taskTag_bentThree": "Tres dobladas", + "taskTag_bigEyeLiberties": "Libertades de ojos grandes", + "taskTag_bigEyeVsSmallEye": "Ojo grande contra ojo pequeño", + "taskTag_bigPoints": "Puntos grandes", + "taskTag_blindSpot": "Punto ciego", + "taskTag_breakEye": "Destruir ojo", + "taskTag_breakEyeInOneStep": "Destruir ojo en una jugada", + "taskTag_breakEyeInSente": "Destruir ojo en sente", + "taskTag_breakOut": "Break out", + "taskTag_breakPoints": "Destruir puntos", + "taskTag_breakShape": "Destruir forma", + "taskTag_bridgeUnder": "Bridge under", + "taskTag_brilliantSequence": "Secuencia brillante", + "taskTag_bulkyFive": "Maquinita", + "taskTag_bump": "Bump", + "taskTag_captureBySnapback": "Captura mediante contracaptura", + "taskTag_captureInLadder": "Captura en escalera", + "taskTag_captureInOneMove": "Captura en una jugada", + "taskTag_captureOnTheSide": "Captura en el lado", + "taskTag_captureToLive": "Captura para vivir", + "taskTag_captureTwoRecaptureOne": "Captura dos, recaptura una", + "taskTag_capturingRace": "Semeai", + "taskTag_capturingTechniques": "Técnicas de captura", + "taskTag_carpentersSquareAndSimilar": "Escuadra de carpintero y similares", + "taskTag_chooseTheFight": "Elige la pelea", + "taskTag_clamp": "Pinza", + "taskTag_clampCapture": "Captura con pinza", + "taskTag_closeInCapture": "Closing-in capture", + "taskTag_combination": "Combinación", + "taskTag_commonLifeAndDeath": "Vida y muerte: formas comunes", + "taskTag_compareSize": "Comparar tamaño", + "taskTag_compareValue": "Comparar valor", + "taskTag_completeKoToSecureEndgameAdvantage": "Toma el ko para asegurar la ventaja de final", + "taskTag_compositeProblems": "Problemas compuestos", + "taskTag_comprehensiveTasks": "Problemas integrales", + "taskTag_connect": "Conecta", + "taskTag_connectAndDie": "Conecta y muere", + "taskTag_connectInOneMove": "Conecta en una jugada", + "taskTag_contactFightTesuji": "Tesuji para peleas de contacto", + "taskTag_contactPlay": "Jugadas de contacto", + "taskTag_corner": "Esquina", + "taskTag_cornerIsGoldSideIsSilverCenterIsGrass": "La esquina es oro, el lado es plata, el centro es hierba", + "taskTag_counter": "Contraataque", + "taskTag_counterAttack": "Contraataque", + "taskTag_cranesNest": "Nido de grulla", + "taskTag_crawl": "Crawl", + "taskTag_createShortageOfLiberties": "Crear falta de libertades", + "taskTag_crossedFive": "Cruz", + "taskTag_cut": "Corte", + "taskTag_cut2": "Corte", + "taskTag_cutAcross": "Atravesar", + "taskTag_defendFromInvasion": "Defensa contra invasiones", + "taskTag_defendPoints": "Defender puntos", + "taskTag_defendWeakPoint": "Defender punto débil", + "taskTag_descent": "Descenso", + "taskTag_diagonal": "Diagonal", + "taskTag_directionOfCapture": "Dirección de captura", + "taskTag_directionOfEscape": "Dirección de escape", + "taskTag_directionOfPlay": "Dirección de juego", + "taskTag_doNotUnderestimateOpponent": "No subestimes al oponente", + "taskTag_doubleAtari": "Atari doble", + "taskTag_doubleCapture": "Captura doble", + "taskTag_doubleKo": "Ko doble", + "taskTag_doubleSenteEndgame": "Final sente doble", + "taskTag_doubleSnapback": "Contracaptura doble", + "taskTag_endgame": "Finales: general", + "taskTag_endgameFundamentals": "Fundamentos de finales", + "taskTag_endgameIn5x5": "Finales en 5x5", + "taskTag_endgameOn4x4": "Finales en 4x4", + "taskTag_endgameTesuji": "Tesuji de finales", + "taskTag_engulfingAtari": "Engulfing atari", + "taskTag_escape": "Escape", + "taskTag_escapeInOneMove": "Escape en una jugada", + "taskTag_exploitShapeWeakness": "Explota la debilidad de la forma", + "taskTag_eyeVsNoEye": "Ojo contra sin-ojo", + "taskTag_fillNeutralPoints": "Jugar puntos neutrales", + "taskTag_findTheRoot": "Find the root", + "taskTag_firstLineBrilliantMove": "Jugada brillante en la primera línea", + "taskTag_flowerSix": "Pececito", + "taskTag_goldenChickenStandingOnOneLeg": "Posición del flamenco", + "taskTag_groupLiberties": "Libertades de grupo", + "taskTag_groupsBase": "Base de grupo", + "taskTag_hane": "Hane", + "taskTag_increaseEyeSpace": "Aumentar espacio vital", + "taskTag_increaseLiberties": "Ganar libertades", + "taskTag_indirectAttack": "Ataque indirecto", + "taskTag_influenceKeyPoints": "Puntos clave de la influencia", + "taskTag_insideKill": "Inside kill", + "taskTag_insideMoves": "Inside moves", + "taskTag_interestingTasks": "Problemas de interés especial", + "taskTag_internalLibertyShortage": "Falta de libertades internas", + "taskTag_invadingTechnique": "Técnicas de invasión", + "taskTag_invasion": "Invasión", + "taskTag_jGroupAndSimilar": "Grupo-J y similares", + "taskTag_josekiFundamentals": "Fundamentos de joseki", + "taskTag_jump": "Salto", + "taskTag_keepSente": "Mantener sente", + "taskTag_killAfterCapture": "Matar mediante captura", + "taskTag_killByEyePointPlacement": "Kill by eye point placement", + "taskTag_knightsMove": "Salto de caballo", + "taskTag_ko": "Ko", + "taskTag_kosumiWedge": "Cuña diagonal", + "taskTag_largeKnightsMove": "Salto grande de caballo", + "taskTag_largeMoyoFight": "Large moyo fight", + "taskTag_lifeAndDeath": "Vida y muerte: general", + "taskTag_lifeAndDeathOn4x4": "Vida y muerte en 4x4", + "taskTag_lookForLeverage": "Look for leverage", + "taskTag_looseLadder": "Escalera larga", + "taskTag_lovesickCut": "Lovesick cut", + "taskTag_makeEye": "Hacer ojo", + "taskTag_makeEyeInOneStep": "Hacer ojo en una jugada", + "taskTag_makeEyeInSente": "Hacer ojo en sente", + "taskTag_makeKo": "Hacer ko", + "taskTag_makeShape": "Hacer forma", + "taskTag_middlegame": "Medio-juego", + "taskTag_monkeyClimbingMountain": "Monkey climbing the mountain", + "taskTag_mouseStealingOil": "Mouse stealing oil", + "taskTag_moveOut": "Move out", + "taskTag_moveTowardsEmptySpace": "Move towards empty space", + "taskTag_multipleBrilliantMoves": "Múltiples jugadas brillantes", + "taskTag_net": "Red", + "taskTag_netCapture": "Captura en red", + "taskTag_observeSubtleDifference": "Observa la diferencia sutil", + "taskTag_occupyEncloseAndApproachCorner": "Ocupar, rodear y acercarse a las esquinas", + "taskTag_oneStoneTwoPurposes": "Una jugada, dos objetivos", + "taskTag_opening": "Apertura", + "taskTag_openingChoice": "Elección de apertura", + "taskTag_openingFundamentals": "Fundamentos de apertura", + "taskTag_orderOfEndgameMoves": "Orden de jugadas de final", + "taskTag_orderOfMoves": "Orden de jugadas", + "taskTag_orderOfMovesInKo": "Orden de jugadas en un ko", + "taskTag_orioleCapturesButterfly": "Oriole captures the butterfly", + "taskTag_pincer": "Pinza", + "taskTag_placement": "Placement", + "taskTag_plunderingTechnique": "Plundering technique", + "taskTag_preventBambooJoint": "Prevén la conección de bambú", + "taskTag_preventBridgingUnder": "Prevent bridging under", + "taskTag_preventOpponentFromApproaching": "Prevent opponent from approaching", + "taskTag_probe": "Jugada de prueba", + "taskTag_profitInSente": "Ganancia en sente", + "taskTag_profitUsingLifeAndDeath": "Ganancia utilizando vida y muerte", + "taskTag_push": "Push", + "taskTag_pyramidFour": "Pirámide", + "taskTag_realEyeAndFalseEye": "Ojo real contra ojo falso", + "taskTag_rectangularSix": "Seis rectangulares", + "taskTag_reduceEyeSpace": "Reducir espacio vital", + "taskTag_reduceLiberties": "Reducir libertades", + "taskTag_reduction": "Reducción", + "taskTag_runWeakGroup": "Huída de grupo débil", + "taskTag_sabakiAndUtilizingInfluence": "Sabaki y el uso de influencia", + "taskTag_sacrifice": "Sacrificio", + "taskTag_sacrificeAndSqueeze": "Sacrifica y exprime", + "taskTag_sealIn": "Sellar", + "taskTag_secondLine": "Segunda línea", + "taskTag_seizeTheOpportunity": "Aprovecha la oportunidad", + "taskTag_seki": "Seki", + "taskTag_senteAndGote": "Sente y gote", + "taskTag_settleShape": "Asienta la forma", + "taskTag_settleShapeInSente": "Asienta la forma en sente", + "taskTag_shape": "Forma", + "taskTag_shapesVitalPoint": "Punto vital de la forma", + "taskTag_side": "Lado", + "taskTag_smallBoardEndgame": "Finales en tableros pequeños", + "taskTag_snapback": "Contracaptura", + "taskTag_solidConnection": "Conección sólida", + "taskTag_solidExtension": "Extensión sólida", + "taskTag_splitInOneMove": "Separar en una jugada", + "taskTag_splittingMove": "Jugada de separación", + "taskTag_squareFour": "Cuatro cuadradas", + "taskTag_squeeze": "Exprime", + "taskTag_standardCapturingRaces": "Semeais estándares", + "taskTag_standardCornerAndSideEndgame": "Finales estándares en la esquina y el lado", + "taskTag_straightFour": "Cuatro en línea", + "taskTag_straightThree": "Tres en línea", + "taskTag_surroundTerritory": "Rodear territorio", + "taskTag_symmetricShape": "Forma simétrica", + "taskTag_techniqueForReinforcingGroups": "Técnicas para reforzar grupos", + "taskTag_techniqueForSecuringTerritory": "Técnicas para asegurar territorio", + "taskTag_textbookTasks": "Problemas de libro", + "taskTag_thirdAndFourthLine": "Tercera y cuarta línea", + "taskTag_threeEyesTwoActions": "Tres ojos, dos jugadas", + "taskTag_threeSpaceExtensionFromTwoStones": "Three-space extension from two stones", + "taskTag_throwIn": "Throw-in", + "taskTag_tigersMouth": "Boca de tigre", + "taskTag_tombstoneSqueeze": "Fantasma cabezón", + "taskTag_tripodGroupWithExtraLegAndSimilar": "Tripod group with extra leg and similar", + "taskTag_twoHaneGainOneLiberty": "Double hane grows one liberty", + "taskTag_twoHeadedDragon": "Dragón de dos cabezas", + "taskTag_twoSpaceExtension": "Extensión de dos puntos", + "taskTag_typesOfKo": "Tipos de ko", + "taskTag_underTheStones": "Under the stones", + "taskTag_underneathAttachment": "Underneath attachment", + "taskTag_urgentPointOfAFight": "Punto urgente de una pelea", + "taskTag_urgentPoints": "Puntos urgentes", + "taskTag_useConnectAndDie": "Usa conecta y muere", + "taskTag_useCornerSpecialProperties": "Usa las propiedades especiales de la esquina", + "taskTag_useDescentToFirstLine": "Usa el descenso a la primera línea", + "taskTag_useInfluence": "Usa la influencia", + "taskTag_useOpponentsLifeAndDeath": "Usa la vida y muerte del oponente", + "taskTag_useShortageOfLiberties": "Usa la falta de libertades", + "taskTag_useSnapback": "Usar contracaptura", + "taskTag_useSurroundingStones": "Usar piedras alrededor", + "taskTag_vitalAndUselessStones": "Piedras vitales e inútiles", + "taskTag_vitalPointForBothSides": "Punto vital para ambos", + "taskTag_vitalPointForCapturingRace": "Punto vital para semeais", + "taskTag_vitalPointForIncreasingLiberties": "Punto vital para aumentar libertades", + "taskTag_vitalPointForKill": "Punto vital para matar", + "taskTag_vitalPointForLife": "Punto vital para vivir", + "taskTag_vitalPointForReducingLiberties": "Punto vital para reducir libertades", + "taskTag_wedge": "Cuña", + "taskTag_wedgingCapture": "Captura usando la cuña", + "taskTimeout": "Se acabó el tiempo", + "taskTypeAppreciation": "Apreciación", + "taskTypeCapture": "Capturar", + "taskTypeCaptureRace": "Semeai", + "taskTypeEndgame": "Finales", + "taskTypeJoseki": "Joseki", + "taskTypeLifeAndDeath": "Vida y muerte", + "taskTypeMiddlegame": "Medio-juego", + "taskTypeOpening": "Apertura", + "taskTypeTesuji": "Tesuji", + "taskTypeTheory": "Teoría", + "taskWrong": "Incorrecto", + "tasksSolved": "Problemas resueltos", + "test": "Probar", + "theme": "Tema", + "thick": "Gruesa", + "timeFrenzy": "Contrarreloj", + "timePerTask": "Tiempo por problema", + "today": "Hoy", + "tooltipAnalyzeWithAISensei": "Analizar con AI Sensei", + "tooltipDownloadGame": "Descargar partida", + "topic": "Tema", + "topicExam": "Examen temático", + "topics": "Temas", + "train": "Entrenar", + "trainingAvgTimePerTask": "Tiempo promedio por problema", + "trainingFailed": "No aprobado", + "trainingMistakes": "Errores", + "trainingPassed": "Aprobado", + "trainingTotalTime": "Tiempo total", + "tryCustomMoves": "Probar otras jugadas", + "tygemDesc": "El servidor más popular de Corea y uno de los más populares del mundo.", + "tygemName": "Tygem Baduk", + "type": "Tipo", + "ui": "Interfaz", + "userInfo": "Perfil de usuario", + "username": "Nombre de usuario", + "voice": "Voz", + "week": "Semana", + "white": "Blancas", + "yes": "Sí" +} \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 00000000..4941dfbe --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,2655 @@ +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_es.dart'; +import 'app_localizations_ru.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('es'), + Locale('ru'), + Locale('zh') + ]; + + /// No description provided for @about. + /// + /// In en, this message translates to: + /// **'About'** + String get about; + + /// No description provided for @accuracy. + /// + /// In en, this message translates to: + /// **'Accuracy'** + String get accuracy; + + /// No description provided for @aiReferee. + /// + /// In en, this message translates to: + /// **'AI referee'** + String get aiReferee; + + /// No description provided for @aiSensei. + /// + /// In en, this message translates to: + /// **'AI Sensei'** + String get aiSensei; + + /// No description provided for @alwaysBlackToPlay. + /// + /// In en, this message translates to: + /// **'Always black-to-play'** + String get alwaysBlackToPlay; + + /// No description provided for @alwaysBlackToPlayDesc. + /// + /// In en, this message translates to: + /// **'Set all tasks as black-to-play to avoid confusion'** + String get alwaysBlackToPlayDesc; + + /// No description provided for @appearance. + /// + /// In en, this message translates to: + /// **'Appearance'** + String get appearance; + + /// No description provided for @autoCounting. + /// + /// In en, this message translates to: + /// **'Auto counting'** + String get autoCounting; + + /// No description provided for @autoMatch. + /// + /// In en, this message translates to: + /// **'Auto-Match'** + String get autoMatch; + + /// No description provided for @behaviour. + /// + /// In en, this message translates to: + /// **'Behaviour'** + String get behaviour; + + /// No description provided for @bestResult. + /// + /// In en, this message translates to: + /// **'Best result'** + String get bestResult; + + /// No description provided for @black. + /// + /// In en, this message translates to: + /// **'Black'** + String get black; + + /// No description provided for @board. + /// + /// In en, this message translates to: + /// **'Board'** + String get board; + + /// No description provided for @boardSize. + /// + /// In en, this message translates to: + /// **'Board size'** + String get boardSize; + + /// No description provided for @boardTheme. + /// + /// In en, this message translates to: + /// **'Board theme'** + String get boardTheme; + + /// No description provided for @byRank. + /// + /// In en, this message translates to: + /// **'By rank'** + String get byRank; + + /// No description provided for @cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// No description provided for @captures. + /// + /// In en, this message translates to: + /// **'Captures'** + String get captures; + + /// No description provided for @collectStats. + /// + /// In en, this message translates to: + /// **'Collect statistics'** + String get collectStats; + + /// No description provided for @collections. + /// + /// In en, this message translates to: + /// **'Collections'** + String get collections; + + /// No description provided for @confirm. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirm; + + /// No description provided for @confirmBoardSize. + /// + /// In en, this message translates to: + /// **'Confirm board size'** + String get confirmBoardSize; + + /// No description provided for @confirmBoardSizeDesc. + /// + /// In en, this message translates to: + /// **'Boards this size or larger require move confirmation'** + String get confirmBoardSizeDesc; + + /// No description provided for @confirmMoves. + /// + /// In en, this message translates to: + /// **'Confirm moves'** + String get confirmMoves; + + /// No description provided for @confirmMovesDesc. + /// + /// In en, this message translates to: + /// **'Double-tap to confirm moves on large boards to avoid misclicks'** + String get confirmMovesDesc; + + /// No description provided for @continue_. + /// + /// In en, this message translates to: + /// **'Continue'** + String get continue_; + + /// No description provided for @copyTaskLink. + /// + /// In en, this message translates to: + /// **'Copy task link'** + String get copyTaskLink; + + /// No description provided for @customExam. + /// + /// In en, this message translates to: + /// **'Custom exam'** + String get customExam; + + /// No description provided for @dark. + /// + /// In en, this message translates to: + /// **'Dark'** + String get dark; + + /// No description provided for @dontShowAgain. + /// + /// In en, this message translates to: + /// **'Don\'t show again'** + String get dontShowAgain; + + /// No description provided for @download. + /// + /// In en, this message translates to: + /// **'Download'** + String get download; + + /// No description provided for @edgeLine. + /// + /// In en, this message translates to: + /// **'Edge line'** + String get edgeLine; + + /// No description provided for @endgameExam. + /// + /// In en, this message translates to: + /// **'Endgame exam'** + String get endgameExam; + + /// No description provided for @enterTaskLink. + /// + /// In en, this message translates to: + /// **'Enter the task link'** + String get enterTaskLink; + + /// No description provided for @errCannotBeEmpty. + /// + /// In en, this message translates to: + /// **'Cannot be empty'** + String get errCannotBeEmpty; + + /// No description provided for @errFailedToDownloadGame. + /// + /// In en, this message translates to: + /// **'Failed to download game'** + String get errFailedToDownloadGame; + + /// No description provided for @errFailedToLoadGameList. + /// + /// In en, this message translates to: + /// **'Failed to load game list. Please try again.'** + String get errFailedToLoadGameList; + + /// No description provided for @errFailedToUploadGameToAISensei. + /// + /// In en, this message translates to: + /// **'Failed to upload game to AI Sensei'** + String get errFailedToUploadGameToAISensei; + + /// No description provided for @errIncorrectUsernameOrPassword. + /// + /// In en, this message translates to: + /// **'Incorrect username or password'** + String get errIncorrectUsernameOrPassword; + + /// No description provided for @errMustBeAtLeast. + /// + /// In en, this message translates to: + /// **'Must be at least {n}'** + String errMustBeAtLeast(num n); + + /// No description provided for @errMustBeAtMost. + /// + /// In en, this message translates to: + /// **'Must be at most {n}'** + String errMustBeAtMost(num n); + + /// No description provided for @errMustBeInteger. + /// + /// In en, this message translates to: + /// **'Must be an integer'** + String get errMustBeInteger; + + /// No description provided for @exit. + /// + /// In en, this message translates to: + /// **'Exit'** + String get exit; + + /// No description provided for @exitTryMode. + /// + /// In en, this message translates to: + /// **'Exit try mode'** + String get exitTryMode; + + /// No description provided for @find. + /// + /// In en, this message translates to: + /// **'Find'** + String get find; + + /// No description provided for @findTask. + /// + /// In en, this message translates to: + /// **'Find task'** + String get findTask; + + /// No description provided for @forceCounting. + /// + /// In en, this message translates to: + /// **'Force counting'** + String get forceCounting; + + /// No description provided for @foxwqDesc. + /// + /// In en, this message translates to: + /// **'The most popular server in China and the world.'** + String get foxwqDesc; + + /// No description provided for @foxwqName. + /// + /// In en, this message translates to: + /// **'Fox Weiqi'** + String get foxwqName; + + /// No description provided for @gameInfo. + /// + /// In en, this message translates to: + /// **'Game info'** + String get gameInfo; + + /// No description provided for @gameRecord. + /// + /// In en, this message translates to: + /// **'Game record'** + String get gameRecord; + + /// No description provided for @gradingExam. + /// + /// In en, this message translates to: + /// **'Grading exam'** + String get gradingExam; + + /// No description provided for @handicap. + /// + /// In en, this message translates to: + /// **'Handicap'** + String get handicap; + + /// No description provided for @help. + /// + /// In en, this message translates to: + /// **'Help'** + String get help; + + /// No description provided for @helpDialogCollections. + /// + /// In en, this message translates to: + /// **'Collections are classic, curated sets of high-quality tasks which hold special value together as a training resource.\n\nThe main goal is to solve a collection with a high success rate. A secondary goal is to solve it as fast as possible.\n\nTo start or continue solving a collection, slide left on the collection tile while in portrait mode or click the Start/Continue buttons while in landscape mode.'** + String get helpDialogCollections; + + /// No description provided for @helpDialogEndgameExam. + /// + /// In en, this message translates to: + /// **'- Endgame exams are sets of 10 endgame tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.'** + String get helpDialogEndgameExam; + + /// No description provided for @helpDialogGradingExam. + /// + /// In en, this message translates to: + /// **'- Grading exams are sets of 10 tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.'** + String get helpDialogGradingExam; + + /// No description provided for @helpDialogRankedMode. + /// + /// In en, this message translates to: + /// **'- Solve tasks without a time limit.\n\n- Task difficulty increases according to how fast you solve them.\n\n- Focus on solving correctly and reach the highest rank possible.'** + String get helpDialogRankedMode; + + /// No description provided for @helpDialogTimeFrenzy. + /// + /// In en, this message translates to: + /// **'- You have 3 minutes to solve as many tasks as possible.\n\n- Tasks get increasingly difficult as you solve them.\n\n- If you make 3 mistakes, you are out.'** + String get helpDialogTimeFrenzy; + + /// No description provided for @home. + /// + /// In en, this message translates to: + /// **'Home'** + String get home; + + /// No description provided for @komi. + /// + /// In en, this message translates to: + /// **'Komi'** + String get komi; + + /// No description provided for @language. + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// No description provided for @leave. + /// + /// In en, this message translates to: + /// **'Leave'** + String get leave; + + /// No description provided for @light. + /// + /// In en, this message translates to: + /// **'Light'** + String get light; + + /// No description provided for @login. + /// + /// In en, this message translates to: + /// **'Login'** + String get login; + + /// No description provided for @logout. + /// + /// In en, this message translates to: + /// **'Logout'** + String get logout; + + /// No description provided for @long. + /// + /// In en, this message translates to: + /// **'Long'** + String get long; + + /// No description provided for @mMinutes. + /// + /// In en, this message translates to: + /// **'{m}min'** + String mMinutes(int m); + + /// No description provided for @maxNumberOfMistakes. + /// + /// In en, this message translates to: + /// **'Maximum number of mistakes'** + String get maxNumberOfMistakes; + + /// No description provided for @maxRank. + /// + /// In en, this message translates to: + /// **'Max rank'** + String get maxRank; + + /// No description provided for @medium. + /// + /// In en, this message translates to: + /// **'Medium'** + String get medium; + + /// No description provided for @minRank. + /// + /// In en, this message translates to: + /// **'Min rank'** + String get minRank; + + /// No description provided for @minutes. + /// + /// In en, this message translates to: + /// **'Minutes'** + String get minutes; + + /// No description provided for @month. + /// + /// In en, this message translates to: + /// **'Month'** + String get month; + + /// No description provided for @msgCannotUseAIRefereeYet. + /// + /// In en, this message translates to: + /// **'AI referee cannot be used yet'** + String get msgCannotUseAIRefereeYet; + + /// No description provided for @msgCannotUseForcedCountingYet. + /// + /// In en, this message translates to: + /// **'Forced counting cannot be used yet'** + String get msgCannotUseForcedCountingYet; + + /// No description provided for @msgConfirmDeleteCollectionProgress. + /// + /// In en, this message translates to: + /// **'Are you sure that you want to delete the previous attempt?'** + String get msgConfirmDeleteCollectionProgress; + + /// No description provided for @msgConfirmResignation. + /// + /// In en, this message translates to: + /// **'Are you sure that you want to resign?'** + String get msgConfirmResignation; + + /// No description provided for @msgConfirmStopEvent. + /// + /// In en, this message translates to: + /// **'Are you sure that you want to stop the {event}?'** + String msgConfirmStopEvent(String event); + + /// No description provided for @msgDownloadingGame. + /// + /// In en, this message translates to: + /// **'Downloading game'** + String get msgDownloadingGame; + + /// No description provided for @msgGameSavedTo. + /// + /// In en, this message translates to: + /// **'Game saved to {path}'** + String msgGameSavedTo(String path); + + /// No description provided for @msgPleaseWaitForYourTurn. + /// + /// In en, this message translates to: + /// **'Please, wait for your turn'** + String get msgPleaseWaitForYourTurn; + + /// No description provided for @msgSearchingForGame. + /// + /// In en, this message translates to: + /// **'Searching for a game...'** + String get msgSearchingForGame; + + /// No description provided for @msgTaskLinkCopied. + /// + /// In en, this message translates to: + /// **'Task link copied.'** + String get msgTaskLinkCopied; + + /// No description provided for @msgWaitingForOpponentsDecision. + /// + /// In en, this message translates to: + /// **'Waiting for your opponent\'s decision...'** + String get msgWaitingForOpponentsDecision; + + /// No description provided for @msgYouCannotPass. + /// + /// In en, this message translates to: + /// **'You cannot pass'** + String get msgYouCannotPass; + + /// No description provided for @msgYourOpponentDisagreesWithCountingResult. + /// + /// In en, this message translates to: + /// **'Your opponent disagrees with the counting result'** + String get msgYourOpponentDisagreesWithCountingResult; + + /// No description provided for @msgYourOpponentRefusesToCount. + /// + /// In en, this message translates to: + /// **'Your opponent refuses to count'** + String get msgYourOpponentRefusesToCount; + + /// No description provided for @msgYourOpponentRequestsAutomaticCounting. + /// + /// In en, this message translates to: + /// **'Your opponent requests automatic counting. Do you agree?'** + String get msgYourOpponentRequestsAutomaticCounting; + + /// No description provided for @myGames. + /// + /// In en, this message translates to: + /// **'My games'** + String get myGames; + + /// No description provided for @myMistakes. + /// + /// In en, this message translates to: + /// **'My mistakes'** + String get myMistakes; + + /// No description provided for @nTasks. + /// + /// In en, this message translates to: + /// **'{count, plural, =0{No tasks} =1{1 task} other{{count} tasks}}'** + String nTasks(int count); + + /// No description provided for @nTasksAvailable. + /// + /// In en, this message translates to: + /// **'{count, plural, =0{No tasks available} =1{1 task available} other{{count} tasks available}}'** + String nTasksAvailable(int count); + + /// No description provided for @newBestResult. + /// + /// In en, this message translates to: + /// **'New best!'** + String get newBestResult; + + /// No description provided for @no. + /// + /// In en, this message translates to: + /// **'No'** + String get no; + + /// No description provided for @none. + /// + /// In en, this message translates to: + /// **'None'** + String get none; + + /// No description provided for @numberOfTasks. + /// + /// In en, this message translates to: + /// **'Number of tasks'** + String get numberOfTasks; + + /// No description provided for @nxnBoardSize. + /// + /// In en, this message translates to: + /// **'{n}×{n}'** + String nxnBoardSize(int n); + + /// No description provided for @ogsDesc. + /// + /// In en, this message translates to: + /// **'The premier online Go platform with tournaments, AI analysis, and a vibrant community.'** + String get ogsDesc; + + /// No description provided for @ogsName. + /// + /// In en, this message translates to: + /// **'Online Go Server'** + String get ogsName; + + /// No description provided for @ok. + /// + /// In en, this message translates to: + /// **'OK'** + String get ok; + + /// No description provided for @pass. + /// + /// In en, this message translates to: + /// **'Pass'** + String get pass; + + /// No description provided for @password. + /// + /// In en, this message translates to: + /// **'Password'** + String get password; + + /// No description provided for @play. + /// + /// In en, this message translates to: + /// **'Play'** + String get play; + + /// No description provided for @promotionRequirements. + /// + /// In en, this message translates to: + /// **'Promotion requirements'** + String get promotionRequirements; + + /// No description provided for @pxsByoyomi. + /// + /// In en, this message translates to: + /// **'{p}×{s}s'** + String pxsByoyomi(int p, int s); + + /// No description provided for @rank. + /// + /// In en, this message translates to: + /// **'Rank'** + String get rank; + + /// No description provided for @rankedMode. + /// + /// In en, this message translates to: + /// **'Ranked mode'** + String get rankedMode; + + /// No description provided for @recentRecord. + /// + /// In en, this message translates to: + /// **'Recent record'** + String get recentRecord; + + /// No description provided for @register. + /// + /// In en, this message translates to: + /// **'Register'** + String get register; + + /// No description provided for @resign. + /// + /// In en, this message translates to: + /// **'Resign'** + String get resign; + + /// No description provided for @responseDelay. + /// + /// In en, this message translates to: + /// **'Response delay'** + String get responseDelay; + + /// No description provided for @responseDelayDesc. + /// + /// In en, this message translates to: + /// **'Duration of the delay before the response appears while solving tasks'** + String get responseDelayDesc; + + /// No description provided for @responseDelayLong. + /// + /// In en, this message translates to: + /// **'Long'** + String get responseDelayLong; + + /// No description provided for @responseDelayMedium. + /// + /// In en, this message translates to: + /// **'Medium'** + String get responseDelayMedium; + + /// No description provided for @responseDelayNone. + /// + /// In en, this message translates to: + /// **'None'** + String get responseDelayNone; + + /// No description provided for @responseDelayShort. + /// + /// In en, this message translates to: + /// **'Short'** + String get responseDelayShort; + + /// No description provided for @result. + /// + /// In en, this message translates to: + /// **'Result'** + String get result; + + /// No description provided for @rules. + /// + /// In en, this message translates to: + /// **'Rules'** + String get rules; + + /// No description provided for @rulesChinese. + /// + /// In en, this message translates to: + /// **'Chinese'** + String get rulesChinese; + + /// No description provided for @rulesJapanese. + /// + /// In en, this message translates to: + /// **'Japanese'** + String get rulesJapanese; + + /// No description provided for @rulesKorean. + /// + /// In en, this message translates to: + /// **'Korean'** + String get rulesKorean; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; + + /// No description provided for @saveSGF. + /// + /// In en, this message translates to: + /// **'Save SGF'** + String get saveSGF; + + /// No description provided for @seconds. + /// + /// In en, this message translates to: + /// **'Seconds'** + String get seconds; + + /// No description provided for @settings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; + + /// No description provided for @short. + /// + /// In en, this message translates to: + /// **'Short'** + String get short; + + /// No description provided for @showCoordinates. + /// + /// In en, this message translates to: + /// **'Show coordinates'** + String get showCoordinates; + + /// No description provided for @simple. + /// + /// In en, this message translates to: + /// **'Simple'** + String get simple; + + /// No description provided for @sortModeDifficult. + /// + /// In en, this message translates to: + /// **'Difficult'** + String get sortModeDifficult; + + /// No description provided for @sortModeRecent. + /// + /// In en, this message translates to: + /// **'Recent'** + String get sortModeRecent; + + /// No description provided for @sound. + /// + /// In en, this message translates to: + /// **'Sound'** + String get sound; + + /// No description provided for @start. + /// + /// In en, this message translates to: + /// **'Start'** + String get start; + + /// No description provided for @statistics. + /// + /// In en, this message translates to: + /// **'Statistics'** + String get statistics; + + /// No description provided for @statsDateColumn. + /// + /// In en, this message translates to: + /// **'Date'** + String get statsDateColumn; + + /// No description provided for @statsDurationColumn. + /// + /// In en, this message translates to: + /// **'Time'** + String get statsDurationColumn; + + /// No description provided for @statsTimeColumn. + /// + /// In en, this message translates to: + /// **'Time'** + String get statsTimeColumn; + + /// No description provided for @stoneShadows. + /// + /// In en, this message translates to: + /// **'Stone shadows'** + String get stoneShadows; + + /// No description provided for @stones. + /// + /// In en, this message translates to: + /// **'Stones'** + String get stones; + + /// No description provided for @subtopic. + /// + /// In en, this message translates to: + /// **'Subtopic'** + String get subtopic; + + /// No description provided for @system. + /// + /// In en, this message translates to: + /// **'System'** + String get system; + + /// No description provided for @task. + /// + /// In en, this message translates to: + /// **'Task'** + String get task; + + /// No description provided for @taskCorrect. + /// + /// In en, this message translates to: + /// **'Correct'** + String get taskCorrect; + + /// No description provided for @taskNext. + /// + /// In en, this message translates to: + /// **'Next'** + String get taskNext; + + /// No description provided for @taskRedo. + /// + /// In en, this message translates to: + /// **'Redo'** + String get taskRedo; + + /// No description provided for @taskSource. + /// + /// In en, this message translates to: + /// **'Task source'** + String get taskSource; + + /// No description provided for @taskSourceFromMyMistakes. + /// + /// In en, this message translates to: + /// **'From my mistakes'** + String get taskSourceFromMyMistakes; + + /// No description provided for @taskSourceFromTaskTopic. + /// + /// In en, this message translates to: + /// **'From task topic'** + String get taskSourceFromTaskTopic; + + /// No description provided for @taskSourceFromTaskTypes. + /// + /// In en, this message translates to: + /// **'From task types'** + String get taskSourceFromTaskTypes; + + /// No description provided for @taskTag_afterJoseki. + /// + /// In en, this message translates to: + /// **'After joseki'** + String get taskTag_afterJoseki; + + /// No description provided for @taskTag_aiOpening. + /// + /// In en, this message translates to: + /// **'AI opening'** + String get taskTag_aiOpening; + + /// No description provided for @taskTag_aiVariations. + /// + /// In en, this message translates to: + /// **'AI variations'** + String get taskTag_aiVariations; + + /// No description provided for @taskTag_attack. + /// + /// In en, this message translates to: + /// **'Attack'** + String get taskTag_attack; + + /// No description provided for @taskTag_attackAndDefenseInKo. + /// + /// In en, this message translates to: + /// **'Attack and defense in a ko'** + String get taskTag_attackAndDefenseInKo; + + /// No description provided for @taskTag_attackAndDefenseOfCuts. + /// + /// In en, this message translates to: + /// **'Attack and defense of cuts'** + String get taskTag_attackAndDefenseOfCuts; + + /// No description provided for @taskTag_attackAndDefenseOfInvadingStones. + /// + /// In en, this message translates to: + /// **'Attack and defense of invading stones'** + String get taskTag_attackAndDefenseOfInvadingStones; + + /// No description provided for @taskTag_avoidKo. + /// + /// In en, this message translates to: + /// **'Avoid ko'** + String get taskTag_avoidKo; + + /// No description provided for @taskTag_avoidMakingDeadShape. + /// + /// In en, this message translates to: + /// **'Avoid making dead shape'** + String get taskTag_avoidMakingDeadShape; + + /// No description provided for @taskTag_avoidTrap. + /// + /// In en, this message translates to: + /// **'Avoid trap'** + String get taskTag_avoidTrap; + + /// No description provided for @taskTag_basicEndgame. + /// + /// In en, this message translates to: + /// **'Endgame: basic'** + String get taskTag_basicEndgame; + + /// No description provided for @taskTag_basicLifeAndDeath. + /// + /// In en, this message translates to: + /// **'Life & death: basic'** + String get taskTag_basicLifeAndDeath; + + /// No description provided for @taskTag_basicMoves. + /// + /// In en, this message translates to: + /// **'Basic moves'** + String get taskTag_basicMoves; + + /// No description provided for @taskTag_basicTesuji. + /// + /// In en, this message translates to: + /// **'Tesuji'** + String get taskTag_basicTesuji; + + /// No description provided for @taskTag_beginner. + /// + /// In en, this message translates to: + /// **'Beginner'** + String get taskTag_beginner; + + /// No description provided for @taskTag_bend. + /// + /// In en, this message translates to: + /// **'Bend'** + String get taskTag_bend; + + /// No description provided for @taskTag_bentFour. + /// + /// In en, this message translates to: + /// **'Bent four'** + String get taskTag_bentFour; + + /// No description provided for @taskTag_bentFourInTheCorner. + /// + /// In en, this message translates to: + /// **'Bent four in the corner'** + String get taskTag_bentFourInTheCorner; + + /// No description provided for @taskTag_bentThree. + /// + /// In en, this message translates to: + /// **'Bent three'** + String get taskTag_bentThree; + + /// No description provided for @taskTag_bigEyeLiberties. + /// + /// In en, this message translates to: + /// **'Big eye\'s liberties'** + String get taskTag_bigEyeLiberties; + + /// No description provided for @taskTag_bigEyeVsSmallEye. + /// + /// In en, this message translates to: + /// **'Big eye vs small eye'** + String get taskTag_bigEyeVsSmallEye; + + /// No description provided for @taskTag_bigPoints. + /// + /// In en, this message translates to: + /// **'Big points'** + String get taskTag_bigPoints; + + /// No description provided for @taskTag_blindSpot. + /// + /// In en, this message translates to: + /// **'Blind spot'** + String get taskTag_blindSpot; + + /// No description provided for @taskTag_breakEye. + /// + /// In en, this message translates to: + /// **'Break eye'** + String get taskTag_breakEye; + + /// No description provided for @taskTag_breakEyeInOneStep. + /// + /// In en, this message translates to: + /// **'Break eye in one step'** + String get taskTag_breakEyeInOneStep; + + /// No description provided for @taskTag_breakEyeInSente. + /// + /// In en, this message translates to: + /// **'Break eye in sente'** + String get taskTag_breakEyeInSente; + + /// No description provided for @taskTag_breakOut. + /// + /// In en, this message translates to: + /// **'Break out'** + String get taskTag_breakOut; + + /// No description provided for @taskTag_breakPoints. + /// + /// In en, this message translates to: + /// **'Break points'** + String get taskTag_breakPoints; + + /// No description provided for @taskTag_breakShape. + /// + /// In en, this message translates to: + /// **'Break shape'** + String get taskTag_breakShape; + + /// No description provided for @taskTag_bridgeUnder. + /// + /// In en, this message translates to: + /// **'Bridge under'** + String get taskTag_bridgeUnder; + + /// No description provided for @taskTag_brilliantSequence. + /// + /// In en, this message translates to: + /// **'Brilliant sequence'** + String get taskTag_brilliantSequence; + + /// No description provided for @taskTag_bulkyFive. + /// + /// In en, this message translates to: + /// **'Bulky five'** + String get taskTag_bulkyFive; + + /// No description provided for @taskTag_bump. + /// + /// In en, this message translates to: + /// **'Bump'** + String get taskTag_bump; + + /// No description provided for @taskTag_captureBySnapback. + /// + /// In en, this message translates to: + /// **'Capture by snapback'** + String get taskTag_captureBySnapback; + + /// No description provided for @taskTag_captureInLadder. + /// + /// In en, this message translates to: + /// **'Capture in ladder'** + String get taskTag_captureInLadder; + + /// No description provided for @taskTag_captureInOneMove. + /// + /// In en, this message translates to: + /// **'Capture in one move'** + String get taskTag_captureInOneMove; + + /// No description provided for @taskTag_captureOnTheSide. + /// + /// In en, this message translates to: + /// **'Capture on the side'** + String get taskTag_captureOnTheSide; + + /// No description provided for @taskTag_captureToLive. + /// + /// In en, this message translates to: + /// **'Capture to live'** + String get taskTag_captureToLive; + + /// No description provided for @taskTag_captureTwoRecaptureOne. + /// + /// In en, this message translates to: + /// **'Capture two, recapture one'** + String get taskTag_captureTwoRecaptureOne; + + /// No description provided for @taskTag_capturingRace. + /// + /// In en, this message translates to: + /// **'Capturing race'** + String get taskTag_capturingRace; + + /// No description provided for @taskTag_capturingTechniques. + /// + /// In en, this message translates to: + /// **'Capturing techniques'** + String get taskTag_capturingTechniques; + + /// No description provided for @taskTag_carpentersSquareAndSimilar. + /// + /// In en, this message translates to: + /// **'Carpenter\'s square and similar'** + String get taskTag_carpentersSquareAndSimilar; + + /// No description provided for @taskTag_chooseTheFight. + /// + /// In en, this message translates to: + /// **'Choose the fight'** + String get taskTag_chooseTheFight; + + /// No description provided for @taskTag_clamp. + /// + /// In en, this message translates to: + /// **'Clamp'** + String get taskTag_clamp; + + /// No description provided for @taskTag_clampCapture. + /// + /// In en, this message translates to: + /// **'Clamp capture'** + String get taskTag_clampCapture; + + /// No description provided for @taskTag_closeInCapture. + /// + /// In en, this message translates to: + /// **'Closing-in capture'** + String get taskTag_closeInCapture; + + /// No description provided for @taskTag_combination. + /// + /// In en, this message translates to: + /// **'Combination'** + String get taskTag_combination; + + /// No description provided for @taskTag_commonLifeAndDeath. + /// + /// In en, this message translates to: + /// **'Life & death: common shapes'** + String get taskTag_commonLifeAndDeath; + + /// No description provided for @taskTag_compareSize. + /// + /// In en, this message translates to: + /// **'Compare size'** + String get taskTag_compareSize; + + /// No description provided for @taskTag_compareValue. + /// + /// In en, this message translates to: + /// **'Compare value'** + String get taskTag_compareValue; + + /// No description provided for @taskTag_completeKoToSecureEndgameAdvantage. + /// + /// In en, this message translates to: + /// **'Complete ko to secure endgame advantage'** + String get taskTag_completeKoToSecureEndgameAdvantage; + + /// No description provided for @taskTag_compositeProblems. + /// + /// In en, this message translates to: + /// **'Composite tasks'** + String get taskTag_compositeProblems; + + /// No description provided for @taskTag_comprehensiveTasks. + /// + /// In en, this message translates to: + /// **'Comprehensive tasks'** + String get taskTag_comprehensiveTasks; + + /// No description provided for @taskTag_connect. + /// + /// In en, this message translates to: + /// **'Connect'** + String get taskTag_connect; + + /// No description provided for @taskTag_connectAndDie. + /// + /// In en, this message translates to: + /// **'Connect and die'** + String get taskTag_connectAndDie; + + /// No description provided for @taskTag_connectInOneMove. + /// + /// In en, this message translates to: + /// **'Connect in one move'** + String get taskTag_connectInOneMove; + + /// No description provided for @taskTag_contactFightTesuji. + /// + /// In en, this message translates to: + /// **'Contact fight tesuji'** + String get taskTag_contactFightTesuji; + + /// No description provided for @taskTag_contactPlay. + /// + /// In en, this message translates to: + /// **'Contact play'** + String get taskTag_contactPlay; + + /// No description provided for @taskTag_corner. + /// + /// In en, this message translates to: + /// **'Corner'** + String get taskTag_corner; + + /// No description provided for @taskTag_cornerIsGoldSideIsSilverCenterIsGrass. + /// + /// In en, this message translates to: + /// **'Corner is gold, side is silver, center is grass'** + String get taskTag_cornerIsGoldSideIsSilverCenterIsGrass; + + /// No description provided for @taskTag_counter. + /// + /// In en, this message translates to: + /// **'Counter'** + String get taskTag_counter; + + /// No description provided for @taskTag_counterAttack. + /// + /// In en, this message translates to: + /// **'Counter-attack'** + String get taskTag_counterAttack; + + /// No description provided for @taskTag_cranesNest. + /// + /// In en, this message translates to: + /// **'Crane\'s nest'** + String get taskTag_cranesNest; + + /// No description provided for @taskTag_crawl. + /// + /// In en, this message translates to: + /// **'Crawl'** + String get taskTag_crawl; + + /// No description provided for @taskTag_createShortageOfLiberties. + /// + /// In en, this message translates to: + /// **'Create shortage of liberties'** + String get taskTag_createShortageOfLiberties; + + /// No description provided for @taskTag_crossedFive. + /// + /// In en, this message translates to: + /// **'Crossed five'** + String get taskTag_crossedFive; + + /// No description provided for @taskTag_cut. + /// + /// In en, this message translates to: + /// **'Cut'** + String get taskTag_cut; + + /// No description provided for @taskTag_cut2. + /// + /// In en, this message translates to: + /// **'Cut'** + String get taskTag_cut2; + + /// No description provided for @taskTag_cutAcross. + /// + /// In en, this message translates to: + /// **'Cut across'** + String get taskTag_cutAcross; + + /// No description provided for @taskTag_defendFromInvasion. + /// + /// In en, this message translates to: + /// **'Defend from invasion'** + String get taskTag_defendFromInvasion; + + /// No description provided for @taskTag_defendPoints. + /// + /// In en, this message translates to: + /// **'Defend points'** + String get taskTag_defendPoints; + + /// No description provided for @taskTag_defendWeakPoint. + /// + /// In en, this message translates to: + /// **'Defend weak point'** + String get taskTag_defendWeakPoint; + + /// No description provided for @taskTag_descent. + /// + /// In en, this message translates to: + /// **'Descent'** + String get taskTag_descent; + + /// No description provided for @taskTag_diagonal. + /// + /// In en, this message translates to: + /// **'Diagonal'** + String get taskTag_diagonal; + + /// No description provided for @taskTag_directionOfCapture. + /// + /// In en, this message translates to: + /// **'Direction of capture'** + String get taskTag_directionOfCapture; + + /// No description provided for @taskTag_directionOfEscape. + /// + /// In en, this message translates to: + /// **'Direction of escape'** + String get taskTag_directionOfEscape; + + /// No description provided for @taskTag_directionOfPlay. + /// + /// In en, this message translates to: + /// **'Direction of play'** + String get taskTag_directionOfPlay; + + /// No description provided for @taskTag_doNotUnderestimateOpponent. + /// + /// In en, this message translates to: + /// **'Do not underestimate opponent'** + String get taskTag_doNotUnderestimateOpponent; + + /// No description provided for @taskTag_doubleAtari. + /// + /// In en, this message translates to: + /// **'Double atari'** + String get taskTag_doubleAtari; + + /// No description provided for @taskTag_doubleCapture. + /// + /// In en, this message translates to: + /// **'Double capture'** + String get taskTag_doubleCapture; + + /// No description provided for @taskTag_doubleKo. + /// + /// In en, this message translates to: + /// **'Double ko'** + String get taskTag_doubleKo; + + /// No description provided for @taskTag_doubleSenteEndgame. + /// + /// In en, this message translates to: + /// **'Double sente endgame'** + String get taskTag_doubleSenteEndgame; + + /// No description provided for @taskTag_doubleSnapback. + /// + /// In en, this message translates to: + /// **'Double snapback'** + String get taskTag_doubleSnapback; + + /// No description provided for @taskTag_endgame. + /// + /// In en, this message translates to: + /// **'Endgame: general'** + String get taskTag_endgame; + + /// No description provided for @taskTag_endgameFundamentals. + /// + /// In en, this message translates to: + /// **'Endgame fundamentals'** + String get taskTag_endgameFundamentals; + + /// No description provided for @taskTag_endgameIn5x5. + /// + /// In en, this message translates to: + /// **'Endgame on 5x5'** + String get taskTag_endgameIn5x5; + + /// No description provided for @taskTag_endgameOn4x4. + /// + /// In en, this message translates to: + /// **'Endgame on 4x4'** + String get taskTag_endgameOn4x4; + + /// No description provided for @taskTag_endgameTesuji. + /// + /// In en, this message translates to: + /// **'Endgame tesuji'** + String get taskTag_endgameTesuji; + + /// No description provided for @taskTag_engulfingAtari. + /// + /// In en, this message translates to: + /// **'Engulfing atari'** + String get taskTag_engulfingAtari; + + /// No description provided for @taskTag_escape. + /// + /// In en, this message translates to: + /// **'Escape'** + String get taskTag_escape; + + /// No description provided for @taskTag_escapeInOneMove. + /// + /// In en, this message translates to: + /// **'Escape in one move'** + String get taskTag_escapeInOneMove; + + /// No description provided for @taskTag_exploitShapeWeakness. + /// + /// In en, this message translates to: + /// **'Exploit shape weakness'** + String get taskTag_exploitShapeWeakness; + + /// No description provided for @taskTag_eyeVsNoEye. + /// + /// In en, this message translates to: + /// **'Eye vs no-eye'** + String get taskTag_eyeVsNoEye; + + /// No description provided for @taskTag_fillNeutralPoints. + /// + /// In en, this message translates to: + /// **'Fill neutral points'** + String get taskTag_fillNeutralPoints; + + /// No description provided for @taskTag_findTheRoot. + /// + /// In en, this message translates to: + /// **'Find the root'** + String get taskTag_findTheRoot; + + /// No description provided for @taskTag_firstLineBrilliantMove. + /// + /// In en, this message translates to: + /// **'First line brilliant move'** + String get taskTag_firstLineBrilliantMove; + + /// No description provided for @taskTag_flowerSix. + /// + /// In en, this message translates to: + /// **'Flower six'** + String get taskTag_flowerSix; + + /// No description provided for @taskTag_goldenChickenStandingOnOneLeg. + /// + /// In en, this message translates to: + /// **'Golden rooster standing on one leg'** + String get taskTag_goldenChickenStandingOnOneLeg; + + /// No description provided for @taskTag_groupLiberties. + /// + /// In en, this message translates to: + /// **'Group liberties'** + String get taskTag_groupLiberties; + + /// No description provided for @taskTag_groupsBase. + /// + /// In en, this message translates to: + /// **'Group\'s base'** + String get taskTag_groupsBase; + + /// No description provided for @taskTag_hane. + /// + /// In en, this message translates to: + /// **'Hane'** + String get taskTag_hane; + + /// No description provided for @taskTag_increaseEyeSpace. + /// + /// In en, this message translates to: + /// **'Increase eye space'** + String get taskTag_increaseEyeSpace; + + /// No description provided for @taskTag_increaseLiberties. + /// + /// In en, this message translates to: + /// **'Increase liberties'** + String get taskTag_increaseLiberties; + + /// No description provided for @taskTag_indirectAttack. + /// + /// In en, this message translates to: + /// **'Indirect attack'** + String get taskTag_indirectAttack; + + /// No description provided for @taskTag_influenceKeyPoints. + /// + /// In en, this message translates to: + /// **'Influence key points'** + String get taskTag_influenceKeyPoints; + + /// No description provided for @taskTag_insideKill. + /// + /// In en, this message translates to: + /// **'Inside kill'** + String get taskTag_insideKill; + + /// No description provided for @taskTag_insideMoves. + /// + /// In en, this message translates to: + /// **'Inside moves'** + String get taskTag_insideMoves; + + /// No description provided for @taskTag_interestingTasks. + /// + /// In en, this message translates to: + /// **'Interesting tasks'** + String get taskTag_interestingTasks; + + /// No description provided for @taskTag_internalLibertyShortage. + /// + /// In en, this message translates to: + /// **'Internal liberty shortage'** + String get taskTag_internalLibertyShortage; + + /// No description provided for @taskTag_invadingTechnique. + /// + /// In en, this message translates to: + /// **'Invading technique'** + String get taskTag_invadingTechnique; + + /// No description provided for @taskTag_invasion. + /// + /// In en, this message translates to: + /// **'Invasion'** + String get taskTag_invasion; + + /// No description provided for @taskTag_jGroupAndSimilar. + /// + /// In en, this message translates to: + /// **'J-group and similar'** + String get taskTag_jGroupAndSimilar; + + /// No description provided for @taskTag_josekiFundamentals. + /// + /// In en, this message translates to: + /// **'Joseki fundamentals'** + String get taskTag_josekiFundamentals; + + /// No description provided for @taskTag_jump. + /// + /// In en, this message translates to: + /// **'Jump'** + String get taskTag_jump; + + /// No description provided for @taskTag_keepSente. + /// + /// In en, this message translates to: + /// **'Keep sente'** + String get taskTag_keepSente; + + /// No description provided for @taskTag_killAfterCapture. + /// + /// In en, this message translates to: + /// **'Kill after capture'** + String get taskTag_killAfterCapture; + + /// No description provided for @taskTag_killByEyePointPlacement. + /// + /// In en, this message translates to: + /// **'Kill by eye point placement'** + String get taskTag_killByEyePointPlacement; + + /// No description provided for @taskTag_knightsMove. + /// + /// In en, this message translates to: + /// **'Knight\'s move'** + String get taskTag_knightsMove; + + /// No description provided for @taskTag_ko. + /// + /// In en, this message translates to: + /// **'Ko'** + String get taskTag_ko; + + /// No description provided for @taskTag_kosumiWedge. + /// + /// In en, this message translates to: + /// **'Kosumi wedge'** + String get taskTag_kosumiWedge; + + /// No description provided for @taskTag_largeKnightsMove. + /// + /// In en, this message translates to: + /// **'Large knight move'** + String get taskTag_largeKnightsMove; + + /// No description provided for @taskTag_largeMoyoFight. + /// + /// In en, this message translates to: + /// **'Large moyo fight'** + String get taskTag_largeMoyoFight; + + /// No description provided for @taskTag_lifeAndDeath. + /// + /// In en, this message translates to: + /// **'Life & death: general'** + String get taskTag_lifeAndDeath; + + /// No description provided for @taskTag_lifeAndDeathOn4x4. + /// + /// In en, this message translates to: + /// **'Life and death on 4x4'** + String get taskTag_lifeAndDeathOn4x4; + + /// No description provided for @taskTag_lookForLeverage. + /// + /// In en, this message translates to: + /// **'Look for leverage'** + String get taskTag_lookForLeverage; + + /// No description provided for @taskTag_looseLadder. + /// + /// In en, this message translates to: + /// **'Loose ladder'** + String get taskTag_looseLadder; + + /// No description provided for @taskTag_lovesickCut. + /// + /// In en, this message translates to: + /// **'Lovesick cut'** + String get taskTag_lovesickCut; + + /// No description provided for @taskTag_makeEye. + /// + /// In en, this message translates to: + /// **'Make eye'** + String get taskTag_makeEye; + + /// No description provided for @taskTag_makeEyeInOneStep. + /// + /// In en, this message translates to: + /// **'Make eye in one step'** + String get taskTag_makeEyeInOneStep; + + /// No description provided for @taskTag_makeEyeInSente. + /// + /// In en, this message translates to: + /// **'Make eye in sente'** + String get taskTag_makeEyeInSente; + + /// No description provided for @taskTag_makeKo. + /// + /// In en, this message translates to: + /// **'Make ko'** + String get taskTag_makeKo; + + /// No description provided for @taskTag_makeShape. + /// + /// In en, this message translates to: + /// **'Make shape'** + String get taskTag_makeShape; + + /// No description provided for @taskTag_middlegame. + /// + /// In en, this message translates to: + /// **'Middlegame'** + String get taskTag_middlegame; + + /// No description provided for @taskTag_monkeyClimbingMountain. + /// + /// In en, this message translates to: + /// **'Monkey climbing the mountain'** + String get taskTag_monkeyClimbingMountain; + + /// No description provided for @taskTag_mouseStealingOil. + /// + /// In en, this message translates to: + /// **'Mouse stealing oil'** + String get taskTag_mouseStealingOil; + + /// No description provided for @taskTag_moveOut. + /// + /// In en, this message translates to: + /// **'Move out'** + String get taskTag_moveOut; + + /// No description provided for @taskTag_moveTowardsEmptySpace. + /// + /// In en, this message translates to: + /// **'Move towards empty space'** + String get taskTag_moveTowardsEmptySpace; + + /// No description provided for @taskTag_multipleBrilliantMoves. + /// + /// In en, this message translates to: + /// **'Multiple brilliant moves'** + String get taskTag_multipleBrilliantMoves; + + /// No description provided for @taskTag_net. + /// + /// In en, this message translates to: + /// **'Net'** + String get taskTag_net; + + /// No description provided for @taskTag_netCapture. + /// + /// In en, this message translates to: + /// **'Net capture'** + String get taskTag_netCapture; + + /// No description provided for @taskTag_observeSubtleDifference. + /// + /// In en, this message translates to: + /// **'Observe subtle difference'** + String get taskTag_observeSubtleDifference; + + /// No description provided for @taskTag_occupyEncloseAndApproachCorner. + /// + /// In en, this message translates to: + /// **'Occupy, enclose and approach corners'** + String get taskTag_occupyEncloseAndApproachCorner; + + /// No description provided for @taskTag_oneStoneTwoPurposes. + /// + /// In en, this message translates to: + /// **'One stone, two purposes'** + String get taskTag_oneStoneTwoPurposes; + + /// No description provided for @taskTag_opening. + /// + /// In en, this message translates to: + /// **'Opening'** + String get taskTag_opening; + + /// No description provided for @taskTag_openingChoice. + /// + /// In en, this message translates to: + /// **'Opening choice'** + String get taskTag_openingChoice; + + /// No description provided for @taskTag_openingFundamentals. + /// + /// In en, this message translates to: + /// **'Opening fundamentals'** + String get taskTag_openingFundamentals; + + /// No description provided for @taskTag_orderOfEndgameMoves. + /// + /// In en, this message translates to: + /// **'Order of endgame moves'** + String get taskTag_orderOfEndgameMoves; + + /// No description provided for @taskTag_orderOfMoves. + /// + /// In en, this message translates to: + /// **'Order of moves'** + String get taskTag_orderOfMoves; + + /// No description provided for @taskTag_orderOfMovesInKo. + /// + /// In en, this message translates to: + /// **'Order of moves in a ko'** + String get taskTag_orderOfMovesInKo; + + /// No description provided for @taskTag_orioleCapturesButterfly. + /// + /// In en, this message translates to: + /// **'Oriole captures the butterfly'** + String get taskTag_orioleCapturesButterfly; + + /// No description provided for @taskTag_pincer. + /// + /// In en, this message translates to: + /// **'Pincer'** + String get taskTag_pincer; + + /// No description provided for @taskTag_placement. + /// + /// In en, this message translates to: + /// **'Placement'** + String get taskTag_placement; + + /// No description provided for @taskTag_plunderingTechnique. + /// + /// In en, this message translates to: + /// **'Plundering technique'** + String get taskTag_plunderingTechnique; + + /// No description provided for @taskTag_preventBambooJoint. + /// + /// In en, this message translates to: + /// **'Prevent the bamboo joint'** + String get taskTag_preventBambooJoint; + + /// No description provided for @taskTag_preventBridgingUnder. + /// + /// In en, this message translates to: + /// **'Prevent bridging under'** + String get taskTag_preventBridgingUnder; + + /// No description provided for @taskTag_preventOpponentFromApproaching. + /// + /// In en, this message translates to: + /// **'Prevent opponent from approaching'** + String get taskTag_preventOpponentFromApproaching; + + /// No description provided for @taskTag_probe. + /// + /// In en, this message translates to: + /// **'Probe'** + String get taskTag_probe; + + /// No description provided for @taskTag_profitInSente. + /// + /// In en, this message translates to: + /// **'Profit in sente'** + String get taskTag_profitInSente; + + /// No description provided for @taskTag_profitUsingLifeAndDeath. + /// + /// In en, this message translates to: + /// **'Profit using life and death'** + String get taskTag_profitUsingLifeAndDeath; + + /// No description provided for @taskTag_push. + /// + /// In en, this message translates to: + /// **'Push'** + String get taskTag_push; + + /// No description provided for @taskTag_pyramidFour. + /// + /// In en, this message translates to: + /// **'Pyramid four'** + String get taskTag_pyramidFour; + + /// No description provided for @taskTag_realEyeAndFalseEye. + /// + /// In en, this message translates to: + /// **'Real eye vs false eye'** + String get taskTag_realEyeAndFalseEye; + + /// No description provided for @taskTag_rectangularSix. + /// + /// In en, this message translates to: + /// **'Rectangular six'** + String get taskTag_rectangularSix; + + /// No description provided for @taskTag_reduceEyeSpace. + /// + /// In en, this message translates to: + /// **'Reduce eye space'** + String get taskTag_reduceEyeSpace; + + /// No description provided for @taskTag_reduceLiberties. + /// + /// In en, this message translates to: + /// **'Reduce liberties'** + String get taskTag_reduceLiberties; + + /// No description provided for @taskTag_reduction. + /// + /// In en, this message translates to: + /// **'Reduction'** + String get taskTag_reduction; + + /// No description provided for @taskTag_runWeakGroup. + /// + /// In en, this message translates to: + /// **'Run weak group'** + String get taskTag_runWeakGroup; + + /// No description provided for @taskTag_sabakiAndUtilizingInfluence. + /// + /// In en, this message translates to: + /// **'Sabaki and utilizing influence'** + String get taskTag_sabakiAndUtilizingInfluence; + + /// No description provided for @taskTag_sacrifice. + /// + /// In en, this message translates to: + /// **'Sacrifice'** + String get taskTag_sacrifice; + + /// No description provided for @taskTag_sacrificeAndSqueeze. + /// + /// In en, this message translates to: + /// **'Sacrifice and squeeze'** + String get taskTag_sacrificeAndSqueeze; + + /// No description provided for @taskTag_sealIn. + /// + /// In en, this message translates to: + /// **'Seal in'** + String get taskTag_sealIn; + + /// No description provided for @taskTag_secondLine. + /// + /// In en, this message translates to: + /// **'Second line'** + String get taskTag_secondLine; + + /// No description provided for @taskTag_seizeTheOpportunity. + /// + /// In en, this message translates to: + /// **'Seize the opportunity'** + String get taskTag_seizeTheOpportunity; + + /// No description provided for @taskTag_seki. + /// + /// In en, this message translates to: + /// **'Seki'** + String get taskTag_seki; + + /// No description provided for @taskTag_senteAndGote. + /// + /// In en, this message translates to: + /// **'Sente and gote'** + String get taskTag_senteAndGote; + + /// No description provided for @taskTag_settleShape. + /// + /// In en, this message translates to: + /// **'Settle shape'** + String get taskTag_settleShape; + + /// No description provided for @taskTag_settleShapeInSente. + /// + /// In en, this message translates to: + /// **'Settle shape in sente'** + String get taskTag_settleShapeInSente; + + /// No description provided for @taskTag_shape. + /// + /// In en, this message translates to: + /// **'Shape'** + String get taskTag_shape; + + /// No description provided for @taskTag_shapesVitalPoint. + /// + /// In en, this message translates to: + /// **'Shape\'s vital point'** + String get taskTag_shapesVitalPoint; + + /// No description provided for @taskTag_side. + /// + /// In en, this message translates to: + /// **'Side'** + String get taskTag_side; + + /// No description provided for @taskTag_smallBoardEndgame. + /// + /// In en, this message translates to: + /// **'Small board endgame'** + String get taskTag_smallBoardEndgame; + + /// No description provided for @taskTag_snapback. + /// + /// In en, this message translates to: + /// **'Snapback'** + String get taskTag_snapback; + + /// No description provided for @taskTag_solidConnection. + /// + /// In en, this message translates to: + /// **'Solid connection'** + String get taskTag_solidConnection; + + /// No description provided for @taskTag_solidExtension. + /// + /// In en, this message translates to: + /// **'Solid extension'** + String get taskTag_solidExtension; + + /// No description provided for @taskTag_splitInOneMove. + /// + /// In en, this message translates to: + /// **'Split in one move'** + String get taskTag_splitInOneMove; + + /// No description provided for @taskTag_splittingMove. + /// + /// In en, this message translates to: + /// **'Splitting move'** + String get taskTag_splittingMove; + + /// No description provided for @taskTag_squareFour. + /// + /// In en, this message translates to: + /// **'Square four'** + String get taskTag_squareFour; + + /// No description provided for @taskTag_squeeze. + /// + /// In en, this message translates to: + /// **'Squeeze'** + String get taskTag_squeeze; + + /// No description provided for @taskTag_standardCapturingRaces. + /// + /// In en, this message translates to: + /// **'Standard capturing races'** + String get taskTag_standardCapturingRaces; + + /// No description provided for @taskTag_standardCornerAndSideEndgame. + /// + /// In en, this message translates to: + /// **'Standard corner and side endgame'** + String get taskTag_standardCornerAndSideEndgame; + + /// No description provided for @taskTag_straightFour. + /// + /// In en, this message translates to: + /// **'Straight four'** + String get taskTag_straightFour; + + /// No description provided for @taskTag_straightThree. + /// + /// In en, this message translates to: + /// **'Straight three'** + String get taskTag_straightThree; + + /// No description provided for @taskTag_surroundTerritory. + /// + /// In en, this message translates to: + /// **'Surround territory'** + String get taskTag_surroundTerritory; + + /// No description provided for @taskTag_symmetricShape. + /// + /// In en, this message translates to: + /// **'Symmetric shape'** + String get taskTag_symmetricShape; + + /// No description provided for @taskTag_techniqueForReinforcingGroups. + /// + /// In en, this message translates to: + /// **'Technique for reinforcing groups'** + String get taskTag_techniqueForReinforcingGroups; + + /// No description provided for @taskTag_techniqueForSecuringTerritory. + /// + /// In en, this message translates to: + /// **'Technique for securing territory'** + String get taskTag_techniqueForSecuringTerritory; + + /// No description provided for @taskTag_textbookTasks. + /// + /// In en, this message translates to: + /// **'Textbook tasks'** + String get taskTag_textbookTasks; + + /// No description provided for @taskTag_thirdAndFourthLine. + /// + /// In en, this message translates to: + /// **'Third and fourth line'** + String get taskTag_thirdAndFourthLine; + + /// No description provided for @taskTag_threeEyesTwoActions. + /// + /// In en, this message translates to: + /// **'Three eyes, two actions'** + String get taskTag_threeEyesTwoActions; + + /// No description provided for @taskTag_threeSpaceExtensionFromTwoStones. + /// + /// In en, this message translates to: + /// **'Three-space extension from two stones'** + String get taskTag_threeSpaceExtensionFromTwoStones; + + /// No description provided for @taskTag_throwIn. + /// + /// In en, this message translates to: + /// **'Throw-in'** + String get taskTag_throwIn; + + /// No description provided for @taskTag_tigersMouth. + /// + /// In en, this message translates to: + /// **'Tiger\'s mouth'** + String get taskTag_tigersMouth; + + /// No description provided for @taskTag_tombstoneSqueeze. + /// + /// In en, this message translates to: + /// **'Tombstone squeeze'** + String get taskTag_tombstoneSqueeze; + + /// No description provided for @taskTag_tripodGroupWithExtraLegAndSimilar. + /// + /// In en, this message translates to: + /// **'Tripod group with extra leg and similar'** + String get taskTag_tripodGroupWithExtraLegAndSimilar; + + /// No description provided for @taskTag_twoHaneGainOneLiberty. + /// + /// In en, this message translates to: + /// **'Double hane grows one liberty'** + String get taskTag_twoHaneGainOneLiberty; + + /// No description provided for @taskTag_twoHeadedDragon. + /// + /// In en, this message translates to: + /// **'Two-headed dragon'** + String get taskTag_twoHeadedDragon; + + /// No description provided for @taskTag_twoSpaceExtension. + /// + /// In en, this message translates to: + /// **'Two-space extension'** + String get taskTag_twoSpaceExtension; + + /// No description provided for @taskTag_typesOfKo. + /// + /// In en, this message translates to: + /// **'Types of ko'** + String get taskTag_typesOfKo; + + /// No description provided for @taskTag_underTheStones. + /// + /// In en, this message translates to: + /// **'Under the stones'** + String get taskTag_underTheStones; + + /// No description provided for @taskTag_underneathAttachment. + /// + /// In en, this message translates to: + /// **'Underneath attachment'** + String get taskTag_underneathAttachment; + + /// No description provided for @taskTag_urgentPointOfAFight. + /// + /// In en, this message translates to: + /// **'Urgent point of a fight'** + String get taskTag_urgentPointOfAFight; + + /// No description provided for @taskTag_urgentPoints. + /// + /// In en, this message translates to: + /// **'Urgent points'** + String get taskTag_urgentPoints; + + /// No description provided for @taskTag_useConnectAndDie. + /// + /// In en, this message translates to: + /// **'Use connect and die'** + String get taskTag_useConnectAndDie; + + /// No description provided for @taskTag_useCornerSpecialProperties. + /// + /// In en, this message translates to: + /// **'Use corner special properties'** + String get taskTag_useCornerSpecialProperties; + + /// No description provided for @taskTag_useDescentToFirstLine. + /// + /// In en, this message translates to: + /// **'Use descent to first line'** + String get taskTag_useDescentToFirstLine; + + /// No description provided for @taskTag_useInfluence. + /// + /// In en, this message translates to: + /// **'Use influence'** + String get taskTag_useInfluence; + + /// No description provided for @taskTag_useOpponentsLifeAndDeath. + /// + /// In en, this message translates to: + /// **'Use opponent\'s life and death'** + String get taskTag_useOpponentsLifeAndDeath; + + /// No description provided for @taskTag_useShortageOfLiberties. + /// + /// In en, this message translates to: + /// **'Use shortage of liberties'** + String get taskTag_useShortageOfLiberties; + + /// No description provided for @taskTag_useSnapback. + /// + /// In en, this message translates to: + /// **'Use snapback'** + String get taskTag_useSnapback; + + /// No description provided for @taskTag_useSurroundingStones. + /// + /// In en, this message translates to: + /// **'Use surrounding stones'** + String get taskTag_useSurroundingStones; + + /// No description provided for @taskTag_vitalAndUselessStones. + /// + /// In en, this message translates to: + /// **'Vital and useless stones'** + String get taskTag_vitalAndUselessStones; + + /// No description provided for @taskTag_vitalPointForBothSides. + /// + /// In en, this message translates to: + /// **'Vital point for both sides'** + String get taskTag_vitalPointForBothSides; + + /// No description provided for @taskTag_vitalPointForCapturingRace. + /// + /// In en, this message translates to: + /// **'Vital point for capturing race'** + String get taskTag_vitalPointForCapturingRace; + + /// No description provided for @taskTag_vitalPointForIncreasingLiberties. + /// + /// In en, this message translates to: + /// **'Vital point for increasing liberties'** + String get taskTag_vitalPointForIncreasingLiberties; + + /// No description provided for @taskTag_vitalPointForKill. + /// + /// In en, this message translates to: + /// **'Vital point for kill'** + String get taskTag_vitalPointForKill; + + /// No description provided for @taskTag_vitalPointForLife. + /// + /// In en, this message translates to: + /// **'Vital point for life'** + String get taskTag_vitalPointForLife; + + /// No description provided for @taskTag_vitalPointForReducingLiberties. + /// + /// In en, this message translates to: + /// **'Vital point for reducing liberties'** + String get taskTag_vitalPointForReducingLiberties; + + /// No description provided for @taskTag_wedge. + /// + /// In en, this message translates to: + /// **'Wedge'** + String get taskTag_wedge; + + /// No description provided for @taskTag_wedgingCapture. + /// + /// In en, this message translates to: + /// **'Wedging capture'** + String get taskTag_wedgingCapture; + + /// No description provided for @taskTimeout. + /// + /// In en, this message translates to: + /// **'Timeout'** + String get taskTimeout; + + /// No description provided for @taskTypeAppreciation. + /// + /// In en, this message translates to: + /// **'Appreciation'** + String get taskTypeAppreciation; + + /// No description provided for @taskTypeCapture. + /// + /// In en, this message translates to: + /// **'Capture stones'** + String get taskTypeCapture; + + /// No description provided for @taskTypeCaptureRace. + /// + /// In en, this message translates to: + /// **'Capture race'** + String get taskTypeCaptureRace; + + /// No description provided for @taskTypeEndgame. + /// + /// In en, this message translates to: + /// **'Endgame'** + String get taskTypeEndgame; + + /// No description provided for @taskTypeJoseki. + /// + /// In en, this message translates to: + /// **'Joseki'** + String get taskTypeJoseki; + + /// No description provided for @taskTypeLifeAndDeath. + /// + /// In en, this message translates to: + /// **'Life & death'** + String get taskTypeLifeAndDeath; + + /// No description provided for @taskTypeMiddlegame. + /// + /// In en, this message translates to: + /// **'Middlegame'** + String get taskTypeMiddlegame; + + /// No description provided for @taskTypeOpening. + /// + /// In en, this message translates to: + /// **'Opening'** + String get taskTypeOpening; + + /// No description provided for @taskTypeTesuji. + /// + /// In en, this message translates to: + /// **'Tesuji'** + String get taskTypeTesuji; + + /// No description provided for @taskTypeTheory. + /// + /// In en, this message translates to: + /// **'Theory'** + String get taskTypeTheory; + + /// No description provided for @taskWrong. + /// + /// In en, this message translates to: + /// **'Wrong'** + String get taskWrong; + + /// No description provided for @tasksSolved. + /// + /// In en, this message translates to: + /// **'Tasks solved'** + String get tasksSolved; + + /// No description provided for @test. + /// + /// In en, this message translates to: + /// **'Test'** + String get test; + + /// No description provided for @theme. + /// + /// In en, this message translates to: + /// **'Theme'** + String get theme; + + /// No description provided for @thick. + /// + /// In en, this message translates to: + /// **'Thick'** + String get thick; + + /// No description provided for @timeFrenzy. + /// + /// In en, this message translates to: + /// **'Time frenzy'** + String get timeFrenzy; + + /// No description provided for @timePerTask. + /// + /// In en, this message translates to: + /// **'Time per task'** + String get timePerTask; + + /// No description provided for @today. + /// + /// In en, this message translates to: + /// **'Today'** + String get today; + + /// No description provided for @tooltipAnalyzeWithAISensei. + /// + /// In en, this message translates to: + /// **'Analyze with AI Sensei'** + String get tooltipAnalyzeWithAISensei; + + /// No description provided for @tooltipDownloadGame. + /// + /// In en, this message translates to: + /// **'Download game'** + String get tooltipDownloadGame; + + /// No description provided for @topic. + /// + /// In en, this message translates to: + /// **'Topic'** + String get topic; + + /// No description provided for @topicExam. + /// + /// In en, this message translates to: + /// **'Topic exam'** + String get topicExam; + + /// No description provided for @topics. + /// + /// In en, this message translates to: + /// **'Topics'** + String get topics; + + /// No description provided for @train. + /// + /// In en, this message translates to: + /// **'Train'** + String get train; + + /// No description provided for @trainingAvgTimePerTask. + /// + /// In en, this message translates to: + /// **'Avg time per task'** + String get trainingAvgTimePerTask; + + /// No description provided for @trainingFailed. + /// + /// In en, this message translates to: + /// **'Failed'** + String get trainingFailed; + + /// No description provided for @trainingMistakes. + /// + /// In en, this message translates to: + /// **'Mistakes'** + String get trainingMistakes; + + /// No description provided for @trainingPassed. + /// + /// In en, this message translates to: + /// **'Passed'** + String get trainingPassed; + + /// No description provided for @trainingTotalTime. + /// + /// In en, this message translates to: + /// **'Total time'** + String get trainingTotalTime; + + /// No description provided for @tryCustomMoves. + /// + /// In en, this message translates to: + /// **'Try custom moves'** + String get tryCustomMoves; + + /// No description provided for @tygemDesc. + /// + /// In en, this message translates to: + /// **'The most popular server in Korea and one of the most popular in the world.'** + String get tygemDesc; + + /// No description provided for @tygemName. + /// + /// In en, this message translates to: + /// **'Tygem Baduk'** + String get tygemName; + + /// No description provided for @type. + /// + /// In en, this message translates to: + /// **'Type'** + String get type; + + /// No description provided for @ui. + /// + /// In en, this message translates to: + /// **'UI'** + String get ui; + + /// No description provided for @userInfo. + /// + /// In en, this message translates to: + /// **'User info'** + String get userInfo; + + /// No description provided for @username. + /// + /// In en, this message translates to: + /// **'Username'** + String get username; + + /// No description provided for @voice. + /// + /// In en, this message translates to: + /// **'Voice'** + String get voice; + + /// No description provided for @week. + /// + /// In en, this message translates to: + /// **'Week'** + String get week; + + /// No description provided for @white. + /// + /// In en, this message translates to: + /// **'White'** + String get white; + + /// No description provided for @yes. + /// + /// In en, this message translates to: + /// **'Yes'** + String get yes; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'es', 'ru', '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 'es': + return AppLocalizationsEs(); + case 'ru': + return AppLocalizationsRu(); + 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 100644 index 00000000..92ba0984 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,1345 @@ +// 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 about => 'About'; + + @override + String get accuracy => 'Accuracy'; + + @override + String get aiReferee => 'AI referee'; + + @override + String get aiSensei => 'AI Sensei'; + + @override + String get alwaysBlackToPlay => 'Always black-to-play'; + + @override + String get alwaysBlackToPlayDesc => + 'Set all tasks as black-to-play to avoid confusion'; + + @override + String get appearance => 'Appearance'; + + @override + String get autoCounting => 'Auto counting'; + + @override + String get autoMatch => 'Auto-Match'; + + @override + String get behaviour => 'Behaviour'; + + @override + String get bestResult => 'Best result'; + + @override + String get black => 'Black'; + + @override + String get board => 'Board'; + + @override + String get boardSize => 'Board size'; + + @override + String get boardTheme => 'Board theme'; + + @override + String get byRank => 'By rank'; + + @override + String get cancel => 'Cancel'; + + @override + String get captures => 'Captures'; + + @override + String get collectStats => 'Collect statistics'; + + @override + String get collections => 'Collections'; + + @override + String get confirm => 'Confirm'; + + @override + String get confirmBoardSize => 'Confirm board size'; + + @override + String get confirmBoardSizeDesc => + 'Boards this size or larger require move confirmation'; + + @override + String get confirmMoves => 'Confirm moves'; + + @override + String get confirmMovesDesc => + 'Double-tap to confirm moves on large boards to avoid misclicks'; + + @override + String get continue_ => 'Continue'; + + @override + String get copyTaskLink => 'Copy task link'; + + @override + String get customExam => 'Custom exam'; + + @override + String get dark => 'Dark'; + + @override + String get dontShowAgain => 'Don\'t show again'; + + @override + String get download => 'Download'; + + @override + String get edgeLine => 'Edge line'; + + @override + String get endgameExam => 'Endgame exam'; + + @override + String get enterTaskLink => 'Enter the task link'; + + @override + String get errCannotBeEmpty => 'Cannot be empty'; + + @override + String get errFailedToDownloadGame => 'Failed to download game'; + + @override + String get errFailedToLoadGameList => + 'Failed to load game list. Please try again.'; + + @override + String get errFailedToUploadGameToAISensei => + 'Failed to upload game to AI Sensei'; + + @override + String get errIncorrectUsernameOrPassword => 'Incorrect username or password'; + + @override + String errMustBeAtLeast(num n) { + return 'Must be at least $n'; + } + + @override + String errMustBeAtMost(num n) { + return 'Must be at most $n'; + } + + @override + String get errMustBeInteger => 'Must be an integer'; + + @override + String get exit => 'Exit'; + + @override + String get exitTryMode => 'Exit try mode'; + + @override + String get find => 'Find'; + + @override + String get findTask => 'Find task'; + + @override + String get forceCounting => 'Force counting'; + + @override + String get foxwqDesc => 'The most popular server in China and the world.'; + + @override + String get foxwqName => 'Fox Weiqi'; + + @override + String get gameInfo => 'Game info'; + + @override + String get gameRecord => 'Game record'; + + @override + String get gradingExam => 'Grading exam'; + + @override + String get handicap => 'Handicap'; + + @override + String get help => 'Help'; + + @override + String get helpDialogCollections => + 'Collections are classic, curated sets of high-quality tasks which hold special value together as a training resource.\n\nThe main goal is to solve a collection with a high success rate. A secondary goal is to solve it as fast as possible.\n\nTo start or continue solving a collection, slide left on the collection tile while in portrait mode or click the Start/Continue buttons while in landscape mode.'; + + @override + String get helpDialogEndgameExam => + '- Endgame exams are sets of 10 endgame tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.'; + + @override + String get helpDialogGradingExam => + '- Grading exams are sets of 10 tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.'; + + @override + String get helpDialogRankedMode => + '- Solve tasks without a time limit.\n\n- Task difficulty increases according to how fast you solve them.\n\n- Focus on solving correctly and reach the highest rank possible.'; + + @override + String get helpDialogTimeFrenzy => + '- You have 3 minutes to solve as many tasks as possible.\n\n- Tasks get increasingly difficult as you solve them.\n\n- If you make 3 mistakes, you are out.'; + + @override + String get home => 'Home'; + + @override + String get komi => 'Komi'; + + @override + String get language => 'Language'; + + @override + String get leave => 'Leave'; + + @override + String get light => 'Light'; + + @override + String get login => 'Login'; + + @override + String get logout => 'Logout'; + + @override + String get long => 'Long'; + + @override + String mMinutes(int m) { + return '${m}min'; + } + + @override + String get maxNumberOfMistakes => 'Maximum number of mistakes'; + + @override + String get maxRank => 'Max rank'; + + @override + String get medium => 'Medium'; + + @override + String get minRank => 'Min rank'; + + @override + String get minutes => 'Minutes'; + + @override + String get month => 'Month'; + + @override + String get msgCannotUseAIRefereeYet => 'AI referee cannot be used yet'; + + @override + String get msgCannotUseForcedCountingYet => + 'Forced counting cannot be used yet'; + + @override + String get msgConfirmDeleteCollectionProgress => + 'Are you sure that you want to delete the previous attempt?'; + + @override + String get msgConfirmResignation => 'Are you sure that you want to resign?'; + + @override + String msgConfirmStopEvent(String event) { + return 'Are you sure that you want to stop the $event?'; + } + + @override + String get msgDownloadingGame => 'Downloading game'; + + @override + String msgGameSavedTo(String path) { + return 'Game saved to $path'; + } + + @override + String get msgPleaseWaitForYourTurn => 'Please, wait for your turn'; + + @override + String get msgSearchingForGame => 'Searching for a game...'; + + @override + String get msgTaskLinkCopied => 'Task link copied.'; + + @override + String get msgWaitingForOpponentsDecision => + 'Waiting for your opponent\'s decision...'; + + @override + String get msgYouCannotPass => 'You cannot pass'; + + @override + String get msgYourOpponentDisagreesWithCountingResult => + 'Your opponent disagrees with the counting result'; + + @override + String get msgYourOpponentRefusesToCount => 'Your opponent refuses to count'; + + @override + String get msgYourOpponentRequestsAutomaticCounting => + 'Your opponent requests automatic counting. Do you agree?'; + + @override + String get myGames => 'My games'; + + @override + String get myMistakes => 'My mistakes'; + + @override + String nTasks(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString tasks', + one: '1 task', + zero: 'No tasks', + ); + return '$_temp0'; + } + + @override + String nTasksAvailable(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString tasks available', + one: '1 task available', + zero: 'No tasks available', + ); + return '$_temp0'; + } + + @override + String get newBestResult => 'New best!'; + + @override + String get no => 'No'; + + @override + String get none => 'None'; + + @override + String get numberOfTasks => 'Number of tasks'; + + @override + String nxnBoardSize(int n) { + return '$n×$n'; + } + + @override + String get ogsDesc => + 'The premier online Go platform with tournaments, AI analysis, and a vibrant community.'; + + @override + String get ogsName => 'Online Go Server'; + + @override + String get ok => 'OK'; + + @override + String get pass => 'Pass'; + + @override + String get password => 'Password'; + + @override + String get play => 'Play'; + + @override + String get promotionRequirements => 'Promotion requirements'; + + @override + String pxsByoyomi(int p, int s) { + return '$p×${s}s'; + } + + @override + String get rank => 'Rank'; + + @override + String get rankedMode => 'Ranked mode'; + + @override + String get recentRecord => 'Recent record'; + + @override + String get register => 'Register'; + + @override + String get resign => 'Resign'; + + @override + String get responseDelay => 'Response delay'; + + @override + String get responseDelayDesc => + 'Duration of the delay before the response appears while solving tasks'; + + @override + String get responseDelayLong => 'Long'; + + @override + String get responseDelayMedium => 'Medium'; + + @override + String get responseDelayNone => 'None'; + + @override + String get responseDelayShort => 'Short'; + + @override + String get result => 'Result'; + + @override + String get rules => 'Rules'; + + @override + String get rulesChinese => 'Chinese'; + + @override + String get rulesJapanese => 'Japanese'; + + @override + String get rulesKorean => 'Korean'; + + @override + String get save => 'Save'; + + @override + String get saveSGF => 'Save SGF'; + + @override + String get seconds => 'Seconds'; + + @override + String get settings => 'Settings'; + + @override + String get short => 'Short'; + + @override + String get showCoordinates => 'Show coordinates'; + + @override + String get simple => 'Simple'; + + @override + String get sortModeDifficult => 'Difficult'; + + @override + String get sortModeRecent => 'Recent'; + + @override + String get sound => 'Sound'; + + @override + String get start => 'Start'; + + @override + String get statistics => 'Statistics'; + + @override + String get statsDateColumn => 'Date'; + + @override + String get statsDurationColumn => 'Time'; + + @override + String get statsTimeColumn => 'Time'; + + @override + String get stoneShadows => 'Stone shadows'; + + @override + String get stones => 'Stones'; + + @override + String get subtopic => 'Subtopic'; + + @override + String get system => 'System'; + + @override + String get task => 'Task'; + + @override + String get taskCorrect => 'Correct'; + + @override + String get taskNext => 'Next'; + + @override + String get taskRedo => 'Redo'; + + @override + String get taskSource => 'Task source'; + + @override + String get taskSourceFromMyMistakes => 'From my mistakes'; + + @override + String get taskSourceFromTaskTopic => 'From task topic'; + + @override + String get taskSourceFromTaskTypes => 'From task types'; + + @override + String get taskTag_afterJoseki => 'After joseki'; + + @override + String get taskTag_aiOpening => 'AI opening'; + + @override + String get taskTag_aiVariations => 'AI variations'; + + @override + String get taskTag_attack => 'Attack'; + + @override + String get taskTag_attackAndDefenseInKo => 'Attack and defense in a ko'; + + @override + String get taskTag_attackAndDefenseOfCuts => 'Attack and defense of cuts'; + + @override + String get taskTag_attackAndDefenseOfInvadingStones => + 'Attack and defense of invading stones'; + + @override + String get taskTag_avoidKo => 'Avoid ko'; + + @override + String get taskTag_avoidMakingDeadShape => 'Avoid making dead shape'; + + @override + String get taskTag_avoidTrap => 'Avoid trap'; + + @override + String get taskTag_basicEndgame => 'Endgame: basic'; + + @override + String get taskTag_basicLifeAndDeath => 'Life & death: basic'; + + @override + String get taskTag_basicMoves => 'Basic moves'; + + @override + String get taskTag_basicTesuji => 'Tesuji'; + + @override + String get taskTag_beginner => 'Beginner'; + + @override + String get taskTag_bend => 'Bend'; + + @override + String get taskTag_bentFour => 'Bent four'; + + @override + String get taskTag_bentFourInTheCorner => 'Bent four in the corner'; + + @override + String get taskTag_bentThree => 'Bent three'; + + @override + String get taskTag_bigEyeLiberties => 'Big eye\'s liberties'; + + @override + String get taskTag_bigEyeVsSmallEye => 'Big eye vs small eye'; + + @override + String get taskTag_bigPoints => 'Big points'; + + @override + String get taskTag_blindSpot => 'Blind spot'; + + @override + String get taskTag_breakEye => 'Break eye'; + + @override + String get taskTag_breakEyeInOneStep => 'Break eye in one step'; + + @override + String get taskTag_breakEyeInSente => 'Break eye in sente'; + + @override + String get taskTag_breakOut => 'Break out'; + + @override + String get taskTag_breakPoints => 'Break points'; + + @override + String get taskTag_breakShape => 'Break shape'; + + @override + String get taskTag_bridgeUnder => 'Bridge under'; + + @override + String get taskTag_brilliantSequence => 'Brilliant sequence'; + + @override + String get taskTag_bulkyFive => 'Bulky five'; + + @override + String get taskTag_bump => 'Bump'; + + @override + String get taskTag_captureBySnapback => 'Capture by snapback'; + + @override + String get taskTag_captureInLadder => 'Capture in ladder'; + + @override + String get taskTag_captureInOneMove => 'Capture in one move'; + + @override + String get taskTag_captureOnTheSide => 'Capture on the side'; + + @override + String get taskTag_captureToLive => 'Capture to live'; + + @override + String get taskTag_captureTwoRecaptureOne => 'Capture two, recapture one'; + + @override + String get taskTag_capturingRace => 'Capturing race'; + + @override + String get taskTag_capturingTechniques => 'Capturing techniques'; + + @override + String get taskTag_carpentersSquareAndSimilar => + 'Carpenter\'s square and similar'; + + @override + String get taskTag_chooseTheFight => 'Choose the fight'; + + @override + String get taskTag_clamp => 'Clamp'; + + @override + String get taskTag_clampCapture => 'Clamp capture'; + + @override + String get taskTag_closeInCapture => 'Closing-in capture'; + + @override + String get taskTag_combination => 'Combination'; + + @override + String get taskTag_commonLifeAndDeath => 'Life & death: common shapes'; + + @override + String get taskTag_compareSize => 'Compare size'; + + @override + String get taskTag_compareValue => 'Compare value'; + + @override + String get taskTag_completeKoToSecureEndgameAdvantage => + 'Complete ko to secure endgame advantage'; + + @override + String get taskTag_compositeProblems => 'Composite tasks'; + + @override + String get taskTag_comprehensiveTasks => 'Comprehensive tasks'; + + @override + String get taskTag_connect => 'Connect'; + + @override + String get taskTag_connectAndDie => 'Connect and die'; + + @override + String get taskTag_connectInOneMove => 'Connect in one move'; + + @override + String get taskTag_contactFightTesuji => 'Contact fight tesuji'; + + @override + String get taskTag_contactPlay => 'Contact play'; + + @override + String get taskTag_corner => 'Corner'; + + @override + String get taskTag_cornerIsGoldSideIsSilverCenterIsGrass => + 'Corner is gold, side is silver, center is grass'; + + @override + String get taskTag_counter => 'Counter'; + + @override + String get taskTag_counterAttack => 'Counter-attack'; + + @override + String get taskTag_cranesNest => 'Crane\'s nest'; + + @override + String get taskTag_crawl => 'Crawl'; + + @override + String get taskTag_createShortageOfLiberties => + 'Create shortage of liberties'; + + @override + String get taskTag_crossedFive => 'Crossed five'; + + @override + String get taskTag_cut => 'Cut'; + + @override + String get taskTag_cut2 => 'Cut'; + + @override + String get taskTag_cutAcross => 'Cut across'; + + @override + String get taskTag_defendFromInvasion => 'Defend from invasion'; + + @override + String get taskTag_defendPoints => 'Defend points'; + + @override + String get taskTag_defendWeakPoint => 'Defend weak point'; + + @override + String get taskTag_descent => 'Descent'; + + @override + String get taskTag_diagonal => 'Diagonal'; + + @override + String get taskTag_directionOfCapture => 'Direction of capture'; + + @override + String get taskTag_directionOfEscape => 'Direction of escape'; + + @override + String get taskTag_directionOfPlay => 'Direction of play'; + + @override + String get taskTag_doNotUnderestimateOpponent => + 'Do not underestimate opponent'; + + @override + String get taskTag_doubleAtari => 'Double atari'; + + @override + String get taskTag_doubleCapture => 'Double capture'; + + @override + String get taskTag_doubleKo => 'Double ko'; + + @override + String get taskTag_doubleSenteEndgame => 'Double sente endgame'; + + @override + String get taskTag_doubleSnapback => 'Double snapback'; + + @override + String get taskTag_endgame => 'Endgame: general'; + + @override + String get taskTag_endgameFundamentals => 'Endgame fundamentals'; + + @override + String get taskTag_endgameIn5x5 => 'Endgame on 5x5'; + + @override + String get taskTag_endgameOn4x4 => 'Endgame on 4x4'; + + @override + String get taskTag_endgameTesuji => 'Endgame tesuji'; + + @override + String get taskTag_engulfingAtari => 'Engulfing atari'; + + @override + String get taskTag_escape => 'Escape'; + + @override + String get taskTag_escapeInOneMove => 'Escape in one move'; + + @override + String get taskTag_exploitShapeWeakness => 'Exploit shape weakness'; + + @override + String get taskTag_eyeVsNoEye => 'Eye vs no-eye'; + + @override + String get taskTag_fillNeutralPoints => 'Fill neutral points'; + + @override + String get taskTag_findTheRoot => 'Find the root'; + + @override + String get taskTag_firstLineBrilliantMove => 'First line brilliant move'; + + @override + String get taskTag_flowerSix => 'Flower six'; + + @override + String get taskTag_goldenChickenStandingOnOneLeg => + 'Golden rooster standing on one leg'; + + @override + String get taskTag_groupLiberties => 'Group liberties'; + + @override + String get taskTag_groupsBase => 'Group\'s base'; + + @override + String get taskTag_hane => 'Hane'; + + @override + String get taskTag_increaseEyeSpace => 'Increase eye space'; + + @override + String get taskTag_increaseLiberties => 'Increase liberties'; + + @override + String get taskTag_indirectAttack => 'Indirect attack'; + + @override + String get taskTag_influenceKeyPoints => 'Influence key points'; + + @override + String get taskTag_insideKill => 'Inside kill'; + + @override + String get taskTag_insideMoves => 'Inside moves'; + + @override + String get taskTag_interestingTasks => 'Interesting tasks'; + + @override + String get taskTag_internalLibertyShortage => 'Internal liberty shortage'; + + @override + String get taskTag_invadingTechnique => 'Invading technique'; + + @override + String get taskTag_invasion => 'Invasion'; + + @override + String get taskTag_jGroupAndSimilar => 'J-group and similar'; + + @override + String get taskTag_josekiFundamentals => 'Joseki fundamentals'; + + @override + String get taskTag_jump => 'Jump'; + + @override + String get taskTag_keepSente => 'Keep sente'; + + @override + String get taskTag_killAfterCapture => 'Kill after capture'; + + @override + String get taskTag_killByEyePointPlacement => 'Kill by eye point placement'; + + @override + String get taskTag_knightsMove => 'Knight\'s move'; + + @override + String get taskTag_ko => 'Ko'; + + @override + String get taskTag_kosumiWedge => 'Kosumi wedge'; + + @override + String get taskTag_largeKnightsMove => 'Large knight move'; + + @override + String get taskTag_largeMoyoFight => 'Large moyo fight'; + + @override + String get taskTag_lifeAndDeath => 'Life & death: general'; + + @override + String get taskTag_lifeAndDeathOn4x4 => 'Life and death on 4x4'; + + @override + String get taskTag_lookForLeverage => 'Look for leverage'; + + @override + String get taskTag_looseLadder => 'Loose ladder'; + + @override + String get taskTag_lovesickCut => 'Lovesick cut'; + + @override + String get taskTag_makeEye => 'Make eye'; + + @override + String get taskTag_makeEyeInOneStep => 'Make eye in one step'; + + @override + String get taskTag_makeEyeInSente => 'Make eye in sente'; + + @override + String get taskTag_makeKo => 'Make ko'; + + @override + String get taskTag_makeShape => 'Make shape'; + + @override + String get taskTag_middlegame => 'Middlegame'; + + @override + String get taskTag_monkeyClimbingMountain => 'Monkey climbing the mountain'; + + @override + String get taskTag_mouseStealingOil => 'Mouse stealing oil'; + + @override + String get taskTag_moveOut => 'Move out'; + + @override + String get taskTag_moveTowardsEmptySpace => 'Move towards empty space'; + + @override + String get taskTag_multipleBrilliantMoves => 'Multiple brilliant moves'; + + @override + String get taskTag_net => 'Net'; + + @override + String get taskTag_netCapture => 'Net capture'; + + @override + String get taskTag_observeSubtleDifference => 'Observe subtle difference'; + + @override + String get taskTag_occupyEncloseAndApproachCorner => + 'Occupy, enclose and approach corners'; + + @override + String get taskTag_oneStoneTwoPurposes => 'One stone, two purposes'; + + @override + String get taskTag_opening => 'Opening'; + + @override + String get taskTag_openingChoice => 'Opening choice'; + + @override + String get taskTag_openingFundamentals => 'Opening fundamentals'; + + @override + String get taskTag_orderOfEndgameMoves => 'Order of endgame moves'; + + @override + String get taskTag_orderOfMoves => 'Order of moves'; + + @override + String get taskTag_orderOfMovesInKo => 'Order of moves in a ko'; + + @override + String get taskTag_orioleCapturesButterfly => 'Oriole captures the butterfly'; + + @override + String get taskTag_pincer => 'Pincer'; + + @override + String get taskTag_placement => 'Placement'; + + @override + String get taskTag_plunderingTechnique => 'Plundering technique'; + + @override + String get taskTag_preventBambooJoint => 'Prevent the bamboo joint'; + + @override + String get taskTag_preventBridgingUnder => 'Prevent bridging under'; + + @override + String get taskTag_preventOpponentFromApproaching => + 'Prevent opponent from approaching'; + + @override + String get taskTag_probe => 'Probe'; + + @override + String get taskTag_profitInSente => 'Profit in sente'; + + @override + String get taskTag_profitUsingLifeAndDeath => 'Profit using life and death'; + + @override + String get taskTag_push => 'Push'; + + @override + String get taskTag_pyramidFour => 'Pyramid four'; + + @override + String get taskTag_realEyeAndFalseEye => 'Real eye vs false eye'; + + @override + String get taskTag_rectangularSix => 'Rectangular six'; + + @override + String get taskTag_reduceEyeSpace => 'Reduce eye space'; + + @override + String get taskTag_reduceLiberties => 'Reduce liberties'; + + @override + String get taskTag_reduction => 'Reduction'; + + @override + String get taskTag_runWeakGroup => 'Run weak group'; + + @override + String get taskTag_sabakiAndUtilizingInfluence => + 'Sabaki and utilizing influence'; + + @override + String get taskTag_sacrifice => 'Sacrifice'; + + @override + String get taskTag_sacrificeAndSqueeze => 'Sacrifice and squeeze'; + + @override + String get taskTag_sealIn => 'Seal in'; + + @override + String get taskTag_secondLine => 'Second line'; + + @override + String get taskTag_seizeTheOpportunity => 'Seize the opportunity'; + + @override + String get taskTag_seki => 'Seki'; + + @override + String get taskTag_senteAndGote => 'Sente and gote'; + + @override + String get taskTag_settleShape => 'Settle shape'; + + @override + String get taskTag_settleShapeInSente => 'Settle shape in sente'; + + @override + String get taskTag_shape => 'Shape'; + + @override + String get taskTag_shapesVitalPoint => 'Shape\'s vital point'; + + @override + String get taskTag_side => 'Side'; + + @override + String get taskTag_smallBoardEndgame => 'Small board endgame'; + + @override + String get taskTag_snapback => 'Snapback'; + + @override + String get taskTag_solidConnection => 'Solid connection'; + + @override + String get taskTag_solidExtension => 'Solid extension'; + + @override + String get taskTag_splitInOneMove => 'Split in one move'; + + @override + String get taskTag_splittingMove => 'Splitting move'; + + @override + String get taskTag_squareFour => 'Square four'; + + @override + String get taskTag_squeeze => 'Squeeze'; + + @override + String get taskTag_standardCapturingRaces => 'Standard capturing races'; + + @override + String get taskTag_standardCornerAndSideEndgame => + 'Standard corner and side endgame'; + + @override + String get taskTag_straightFour => 'Straight four'; + + @override + String get taskTag_straightThree => 'Straight three'; + + @override + String get taskTag_surroundTerritory => 'Surround territory'; + + @override + String get taskTag_symmetricShape => 'Symmetric shape'; + + @override + String get taskTag_techniqueForReinforcingGroups => + 'Technique for reinforcing groups'; + + @override + String get taskTag_techniqueForSecuringTerritory => + 'Technique for securing territory'; + + @override + String get taskTag_textbookTasks => 'Textbook tasks'; + + @override + String get taskTag_thirdAndFourthLine => 'Third and fourth line'; + + @override + String get taskTag_threeEyesTwoActions => 'Three eyes, two actions'; + + @override + String get taskTag_threeSpaceExtensionFromTwoStones => + 'Three-space extension from two stones'; + + @override + String get taskTag_throwIn => 'Throw-in'; + + @override + String get taskTag_tigersMouth => 'Tiger\'s mouth'; + + @override + String get taskTag_tombstoneSqueeze => 'Tombstone squeeze'; + + @override + String get taskTag_tripodGroupWithExtraLegAndSimilar => + 'Tripod group with extra leg and similar'; + + @override + String get taskTag_twoHaneGainOneLiberty => 'Double hane grows one liberty'; + + @override + String get taskTag_twoHeadedDragon => 'Two-headed dragon'; + + @override + String get taskTag_twoSpaceExtension => 'Two-space extension'; + + @override + String get taskTag_typesOfKo => 'Types of ko'; + + @override + String get taskTag_underTheStones => 'Under the stones'; + + @override + String get taskTag_underneathAttachment => 'Underneath attachment'; + + @override + String get taskTag_urgentPointOfAFight => 'Urgent point of a fight'; + + @override + String get taskTag_urgentPoints => 'Urgent points'; + + @override + String get taskTag_useConnectAndDie => 'Use connect and die'; + + @override + String get taskTag_useCornerSpecialProperties => + 'Use corner special properties'; + + @override + String get taskTag_useDescentToFirstLine => 'Use descent to first line'; + + @override + String get taskTag_useInfluence => 'Use influence'; + + @override + String get taskTag_useOpponentsLifeAndDeath => + 'Use opponent\'s life and death'; + + @override + String get taskTag_useShortageOfLiberties => 'Use shortage of liberties'; + + @override + String get taskTag_useSnapback => 'Use snapback'; + + @override + String get taskTag_useSurroundingStones => 'Use surrounding stones'; + + @override + String get taskTag_vitalAndUselessStones => 'Vital and useless stones'; + + @override + String get taskTag_vitalPointForBothSides => 'Vital point for both sides'; + + @override + String get taskTag_vitalPointForCapturingRace => + 'Vital point for capturing race'; + + @override + String get taskTag_vitalPointForIncreasingLiberties => + 'Vital point for increasing liberties'; + + @override + String get taskTag_vitalPointForKill => 'Vital point for kill'; + + @override + String get taskTag_vitalPointForLife => 'Vital point for life'; + + @override + String get taskTag_vitalPointForReducingLiberties => + 'Vital point for reducing liberties'; + + @override + String get taskTag_wedge => 'Wedge'; + + @override + String get taskTag_wedgingCapture => 'Wedging capture'; + + @override + String get taskTimeout => 'Timeout'; + + @override + String get taskTypeAppreciation => 'Appreciation'; + + @override + String get taskTypeCapture => 'Capture stones'; + + @override + String get taskTypeCaptureRace => 'Capture race'; + + @override + String get taskTypeEndgame => 'Endgame'; + + @override + String get taskTypeJoseki => 'Joseki'; + + @override + String get taskTypeLifeAndDeath => 'Life & death'; + + @override + String get taskTypeMiddlegame => 'Middlegame'; + + @override + String get taskTypeOpening => 'Opening'; + + @override + String get taskTypeTesuji => 'Tesuji'; + + @override + String get taskTypeTheory => 'Theory'; + + @override + String get taskWrong => 'Wrong'; + + @override + String get tasksSolved => 'Tasks solved'; + + @override + String get test => 'Test'; + + @override + String get theme => 'Theme'; + + @override + String get thick => 'Thick'; + + @override + String get timeFrenzy => 'Time frenzy'; + + @override + String get timePerTask => 'Time per task'; + + @override + String get today => 'Today'; + + @override + String get tooltipAnalyzeWithAISensei => 'Analyze with AI Sensei'; + + @override + String get tooltipDownloadGame => 'Download game'; + + @override + String get topic => 'Topic'; + + @override + String get topicExam => 'Topic exam'; + + @override + String get topics => 'Topics'; + + @override + String get train => 'Train'; + + @override + String get trainingAvgTimePerTask => 'Avg time per task'; + + @override + String get trainingFailed => 'Failed'; + + @override + String get trainingMistakes => 'Mistakes'; + + @override + String get trainingPassed => 'Passed'; + + @override + String get trainingTotalTime => 'Total time'; + + @override + String get tryCustomMoves => 'Try custom moves'; + + @override + String get tygemDesc => + 'The most popular server in Korea and one of the most popular in the world.'; + + @override + String get tygemName => 'Tygem Baduk'; + + @override + String get type => 'Type'; + + @override + String get ui => 'UI'; + + @override + String get userInfo => 'User info'; + + @override + String get username => 'Username'; + + @override + String get voice => 'Voice'; + + @override + String get week => 'Week'; + + @override + String get white => 'White'; + + @override + String get yes => 'Yes'; +} diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart new file mode 100644 index 00000000..15efabe3 --- /dev/null +++ b/lib/l10n/app_localizations_es.dart @@ -0,0 +1,1348 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get about => 'Acerca de WeiqiHub'; + + @override + String get accuracy => 'Precisión'; + + @override + String get aiReferee => 'Árbitro IA'; + + @override + String get aiSensei => 'AI Sensei'; + + @override + String get alwaysBlackToPlay => 'Siempre juegan las negras'; + + @override + String get alwaysBlackToPlayDesc => + 'Hace que en todos los problemas jueguen las negras para evitar confusión'; + + @override + String get appearance => 'Apariencia'; + + @override + String get autoCounting => 'Conteo automático'; + + @override + String get autoMatch => 'Buscar partida'; + + @override + String get behaviour => 'Comportamiento'; + + @override + String get bestResult => 'Mejor resultado'; + + @override + String get black => 'Negras'; + + @override + String get board => 'Tablero'; + + @override + String get boardSize => 'Tamaño del tablero'; + + @override + String get boardTheme => 'Tema del tablero'; + + @override + String get byRank => 'Por rango'; + + @override + String get cancel => 'Cancelar'; + + @override + String get captures => 'Capturas'; + + @override + String get collectStats => 'Añadir a las estadísticas'; + + @override + String get collections => 'Colecciones'; + + @override + String get confirm => 'Confirmar'; + + @override + String get confirmBoardSize => 'Tamaño de confirmación'; + + @override + String get confirmBoardSizeDesc => + 'Tableros de este tamaño o mayores requieren confirmar jugadas'; + + @override + String get confirmMoves => 'Confirmar jugada'; + + @override + String get confirmMovesDesc => + 'Doble tap para confirmar jugadas en tableros grandes para evitar accidentes'; + + @override + String get continue_ => 'Continuar'; + + @override + String get copyTaskLink => 'Copiar enlace al problema'; + + @override + String get customExam => 'Examen personalizado'; + + @override + String get dark => 'Oscuro'; + + @override + String get dontShowAgain => 'No volver a mostrar'; + + @override + String get download => 'Descargar'; + + @override + String get edgeLine => 'Línea de borde'; + + @override + String get endgameExam => 'Examen de finales'; + + @override + String get enterTaskLink => 'Introduce el enlace al problema'; + + @override + String get errCannotBeEmpty => 'No puede estar vacío'; + + @override + String get errFailedToDownloadGame => 'Error descargando partida.'; + + @override + String get errFailedToLoadGameList => + 'Error cargando lista de partidas. Por favor, prueba de nuevo.'; + + @override + String get errFailedToUploadGameToAISensei => + 'Error enviando partida a AI Sensei'; + + @override + String get errIncorrectUsernameOrPassword => + 'Nombre de usuario o contraseña incorrectos'; + + @override + String errMustBeAtLeast(num n) { + return 'No puede ser menor que $n'; + } + + @override + String errMustBeAtMost(num n) { + return 'No puede ser mayor que $n'; + } + + @override + String get errMustBeInteger => 'Debe ser un número entero'; + + @override + String get exit => 'Salir'; + + @override + String get exitTryMode => 'Regresar'; + + @override + String get find => 'Buscar'; + + @override + String get findTask => 'Buscar problema'; + + @override + String get forceCounting => 'Forzar conteo'; + + @override + String get foxwqDesc => 'El servidor más popular de China y el mundo.'; + + @override + String get foxwqName => 'Fox Weiqi'; + + @override + String get gameInfo => 'Información de la partida'; + + @override + String get gameRecord => 'Partida'; + + @override + String get gradingExam => 'Examen de rango'; + + @override + String get handicap => 'Handicap'; + + @override + String get help => 'Ayuda'; + + @override + String get helpDialogCollections => + 'Las colecciones son libros clásicos de problemas de alta calidad que tienen un valor especial para el entrenamiento.\n\nEl objectivo principal es resolver una colección con un alto porcentaje de éxito. El objetivo secundario es resolver una colección lo más rápido posible.\n\nPara comenzar o continuar resolviendo una colección, desliza la colección hacia la izquierda en modo retrato, o haz click en el botón Comenzar/Continuar en modo paisaje.'; + + @override + String get helpDialogEndgameExam => + '- El examen de finales consiste de 10 problemas de finales y tienes 45 segundos para cada problema.\n\n- Apruebas el examen si resuelves 8 o más problemas correctamente (porcentaje de éxito de 80%).\n\n- Si apruebas el examen de un rango, desbloqueas el examen del rango siguiente.'; + + @override + String get helpDialogGradingExam => + '- El examen de rango consiste de 10 problemas y tienes 45 segundos para cada problema.\n\n- Apruebas el examen si resuelves 8 o más problemas correctamente (porcentaje de éxito de 80%).\n\n- Si apruebas el examen de un rango, desbloqueas el examen del rango siguiente.'; + + @override + String get helpDialogRankedMode => + '- Resuelve problemas sin límite de tiempo.\n\n- La dificultad de los problemas aumenta de acuerdo a qué tan rápido los resuelves.\n\n- Concéntrate en resolver problemas correctamente y alcanzar el rango más alto posible.'; + + @override + String get helpDialogTimeFrenzy => + '- Tienes 3 minutos para resolver tantos problemas como sea posible.\n\n- La dificultad de los problemas aumenta a medida que los resuelves.\n\n- Si fallas 3 problemas, el contrarreloj termina.'; + + @override + String get home => 'Inicio'; + + @override + String get komi => 'Komi'; + + @override + String get language => 'Idioma'; + + @override + String get leave => 'Salir'; + + @override + String get light => 'Claro'; + + @override + String get login => 'Entrar'; + + @override + String get logout => 'Salir'; + + @override + String get long => 'Larga'; + + @override + String mMinutes(int m) { + return '${m}min'; + } + + @override + String get maxNumberOfMistakes => 'Número máximo de errores'; + + @override + String get maxRank => 'Rango máximo'; + + @override + String get medium => 'Media'; + + @override + String get minRank => 'Rango mínimo'; + + @override + String get minutes => 'Minutos'; + + @override + String get month => 'Mes'; + + @override + String get msgCannotUseAIRefereeYet => + 'El árbitro IA no está disponible todavía'; + + @override + String get msgCannotUseForcedCountingYet => + 'No es posible forzar el conteo automático todavía'; + + @override + String get msgConfirmDeleteCollectionProgress => + '¿Estás seguro(a) de que quieres abandonar el intento anterior?'; + + @override + String get msgConfirmResignation => + '¿Estás seguro(a) de que quieres abandonar?'; + + @override + String msgConfirmStopEvent(String event) { + return '¿Estás seguro(a) de que quieres abandonar el $event?'; + } + + @override + String get msgDownloadingGame => 'Descargando partida'; + + @override + String msgGameSavedTo(String path) { + return 'Partida guardada en $path'; + } + + @override + String get msgPleaseWaitForYourTurn => 'Por favor, espera tu turno'; + + @override + String get msgSearchingForGame => 'Buscando partida...'; + + @override + String get msgTaskLinkCopied => 'Enlace al problema copiado.'; + + @override + String get msgWaitingForOpponentsDecision => + 'Esperando la decisión de tu oponente...'; + + @override + String get msgYouCannotPass => 'No puedes pasar'; + + @override + String get msgYourOpponentDisagreesWithCountingResult => + 'Tu oponente no está de acuerdo con el resultado del conteo'; + + @override + String get msgYourOpponentRefusesToCount => + 'Tu oponente no acepta el conteo automático.'; + + @override + String get msgYourOpponentRequestsAutomaticCounting => + 'Tu oponente pide conteo automático. ¿Aceptas?'; + + @override + String get myGames => 'Mis partidas'; + + @override + String get myMistakes => 'Mis errores'; + + @override + String nTasks(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString problemas', + one: '1 problema', + zero: 'No hay problemas', + ); + return '$_temp0'; + } + + @override + String nTasksAvailable(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString problemas disponibles', + one: '1 problema disponible', + zero: 'No hay problems disponibles', + ); + return '$_temp0'; + } + + @override + String get newBestResult => 'Nuevo record!'; + + @override + String get no => 'No'; + + @override + String get none => 'Ninguna'; + + @override + String get numberOfTasks => 'Número de problemas'; + + @override + String nxnBoardSize(int n) { + return '$n×$n'; + } + + @override + String get ogsDesc => + 'La principal plataforma en línea de Go, con torneos, análisis con IA y una vibrante comunidad.'; + + @override + String get ogsName => 'Online Go Server'; + + @override + String get ok => 'OK'; + + @override + String get pass => 'Pasar'; + + @override + String get password => 'Contraseña'; + + @override + String get play => 'Jugar'; + + @override + String get promotionRequirements => 'Requisitos de promoción'; + + @override + String pxsByoyomi(int p, int s) { + return '$p×${s}s'; + } + + @override + String get rank => 'Rango'; + + @override + String get rankedMode => 'Clasificatorio'; + + @override + String get recentRecord => 'Resultados recientes'; + + @override + String get register => 'Registrarse'; + + @override + String get resign => 'Abandonar'; + + @override + String get responseDelay => 'Demora de respuesta'; + + @override + String get responseDelayDesc => + 'Demora de la respuesta del oponente mientras resuelves problemas'; + + @override + String get responseDelayLong => 'Larga'; + + @override + String get responseDelayMedium => 'Media'; + + @override + String get responseDelayNone => 'Sin demora'; + + @override + String get responseDelayShort => 'Corta'; + + @override + String get result => 'Resultado'; + + @override + String get rules => 'Reglas'; + + @override + String get rulesChinese => 'Chinas'; + + @override + String get rulesJapanese => 'Japonesas'; + + @override + String get rulesKorean => 'Coreanas'; + + @override + String get save => 'Guardar'; + + @override + String get saveSGF => 'Guardar SGF'; + + @override + String get seconds => 'Segundos'; + + @override + String get settings => 'Preferencias'; + + @override + String get short => 'Corta'; + + @override + String get showCoordinates => 'Mostrar coordenadas'; + + @override + String get simple => 'Simple'; + + @override + String get sortModeDifficult => 'Difíciles'; + + @override + String get sortModeRecent => 'Recientes'; + + @override + String get sound => 'Sonido'; + + @override + String get start => 'Comenzar'; + + @override + String get statistics => 'Estadísticas'; + + @override + String get statsDateColumn => 'Fecha'; + + @override + String get statsDurationColumn => 'Tiempo'; + + @override + String get statsTimeColumn => 'Hora'; + + @override + String get stoneShadows => 'Sombra de las piedras'; + + @override + String get stones => 'Piedras'; + + @override + String get subtopic => 'Subtema'; + + @override + String get system => 'Sistema'; + + @override + String get task => 'Problema'; + + @override + String get taskCorrect => 'Correcto'; + + @override + String get taskNext => 'Siguiente'; + + @override + String get taskRedo => 'Reintentar'; + + @override + String get taskSource => 'Origen de problemas'; + + @override + String get taskSourceFromMyMistakes => 'Mis errores'; + + @override + String get taskSourceFromTaskTopic => 'Por tema'; + + @override + String get taskSourceFromTaskTypes => 'Por tipos de problema'; + + @override + String get taskTag_afterJoseki => 'Continuaciones de joseki'; + + @override + String get taskTag_aiOpening => 'Apertura de IA'; + + @override + String get taskTag_aiVariations => 'Variaciones de IA'; + + @override + String get taskTag_attack => 'Ataque'; + + @override + String get taskTag_attackAndDefenseInKo => 'Ataque y defensa en ko'; + + @override + String get taskTag_attackAndDefenseOfCuts => 'Ataque y defensa de cortes'; + + @override + String get taskTag_attackAndDefenseOfInvadingStones => + 'Ataque y defensa de piedras de invasión'; + + @override + String get taskTag_avoidKo => 'Evitar ko'; + + @override + String get taskTag_avoidMakingDeadShape => 'Evitar forma muerta'; + + @override + String get taskTag_avoidTrap => 'Evitar trampas'; + + @override + String get taskTag_basicEndgame => 'Finales: básico'; + + @override + String get taskTag_basicLifeAndDeath => 'Vida y muerte: básico'; + + @override + String get taskTag_basicMoves => 'Jugadas básicas'; + + @override + String get taskTag_basicTesuji => 'Tesuji'; + + @override + String get taskTag_beginner => 'Principiante'; + + @override + String get taskTag_bend => 'Doblar'; + + @override + String get taskTag_bentFour => 'Cuatro dobladas'; + + @override + String get taskTag_bentFourInTheCorner => 'Cuatro dobladas en la esquina'; + + @override + String get taskTag_bentThree => 'Tres dobladas'; + + @override + String get taskTag_bigEyeLiberties => 'Libertades de ojos grandes'; + + @override + String get taskTag_bigEyeVsSmallEye => 'Ojo grande contra ojo pequeño'; + + @override + String get taskTag_bigPoints => 'Puntos grandes'; + + @override + String get taskTag_blindSpot => 'Punto ciego'; + + @override + String get taskTag_breakEye => 'Destruir ojo'; + + @override + String get taskTag_breakEyeInOneStep => 'Destruir ojo en una jugada'; + + @override + String get taskTag_breakEyeInSente => 'Destruir ojo en sente'; + + @override + String get taskTag_breakOut => 'Break out'; + + @override + String get taskTag_breakPoints => 'Destruir puntos'; + + @override + String get taskTag_breakShape => 'Destruir forma'; + + @override + String get taskTag_bridgeUnder => 'Bridge under'; + + @override + String get taskTag_brilliantSequence => 'Secuencia brillante'; + + @override + String get taskTag_bulkyFive => 'Maquinita'; + + @override + String get taskTag_bump => 'Bump'; + + @override + String get taskTag_captureBySnapback => 'Captura mediante contracaptura'; + + @override + String get taskTag_captureInLadder => 'Captura en escalera'; + + @override + String get taskTag_captureInOneMove => 'Captura en una jugada'; + + @override + String get taskTag_captureOnTheSide => 'Captura en el lado'; + + @override + String get taskTag_captureToLive => 'Captura para vivir'; + + @override + String get taskTag_captureTwoRecaptureOne => 'Captura dos, recaptura una'; + + @override + String get taskTag_capturingRace => 'Semeai'; + + @override + String get taskTag_capturingTechniques => 'Técnicas de captura'; + + @override + String get taskTag_carpentersSquareAndSimilar => + 'Escuadra de carpintero y similares'; + + @override + String get taskTag_chooseTheFight => 'Elige la pelea'; + + @override + String get taskTag_clamp => 'Pinza'; + + @override + String get taskTag_clampCapture => 'Captura con pinza'; + + @override + String get taskTag_closeInCapture => 'Closing-in capture'; + + @override + String get taskTag_combination => 'Combinación'; + + @override + String get taskTag_commonLifeAndDeath => 'Vida y muerte: formas comunes'; + + @override + String get taskTag_compareSize => 'Comparar tamaño'; + + @override + String get taskTag_compareValue => 'Comparar valor'; + + @override + String get taskTag_completeKoToSecureEndgameAdvantage => + 'Toma el ko para asegurar la ventaja de final'; + + @override + String get taskTag_compositeProblems => 'Problemas compuestos'; + + @override + String get taskTag_comprehensiveTasks => 'Problemas integrales'; + + @override + String get taskTag_connect => 'Conecta'; + + @override + String get taskTag_connectAndDie => 'Conecta y muere'; + + @override + String get taskTag_connectInOneMove => 'Conecta en una jugada'; + + @override + String get taskTag_contactFightTesuji => 'Tesuji para peleas de contacto'; + + @override + String get taskTag_contactPlay => 'Jugadas de contacto'; + + @override + String get taskTag_corner => 'Esquina'; + + @override + String get taskTag_cornerIsGoldSideIsSilverCenterIsGrass => + 'La esquina es oro, el lado es plata, el centro es hierba'; + + @override + String get taskTag_counter => 'Contraataque'; + + @override + String get taskTag_counterAttack => 'Contraataque'; + + @override + String get taskTag_cranesNest => 'Nido de grulla'; + + @override + String get taskTag_crawl => 'Crawl'; + + @override + String get taskTag_createShortageOfLiberties => 'Crear falta de libertades'; + + @override + String get taskTag_crossedFive => 'Cruz'; + + @override + String get taskTag_cut => 'Corte'; + + @override + String get taskTag_cut2 => 'Corte'; + + @override + String get taskTag_cutAcross => 'Atravesar'; + + @override + String get taskTag_defendFromInvasion => 'Defensa contra invasiones'; + + @override + String get taskTag_defendPoints => 'Defender puntos'; + + @override + String get taskTag_defendWeakPoint => 'Defender punto débil'; + + @override + String get taskTag_descent => 'Descenso'; + + @override + String get taskTag_diagonal => 'Diagonal'; + + @override + String get taskTag_directionOfCapture => 'Dirección de captura'; + + @override + String get taskTag_directionOfEscape => 'Dirección de escape'; + + @override + String get taskTag_directionOfPlay => 'Dirección de juego'; + + @override + String get taskTag_doNotUnderestimateOpponent => 'No subestimes al oponente'; + + @override + String get taskTag_doubleAtari => 'Atari doble'; + + @override + String get taskTag_doubleCapture => 'Captura doble'; + + @override + String get taskTag_doubleKo => 'Ko doble'; + + @override + String get taskTag_doubleSenteEndgame => 'Final sente doble'; + + @override + String get taskTag_doubleSnapback => 'Contracaptura doble'; + + @override + String get taskTag_endgame => 'Finales: general'; + + @override + String get taskTag_endgameFundamentals => 'Fundamentos de finales'; + + @override + String get taskTag_endgameIn5x5 => 'Finales en 5x5'; + + @override + String get taskTag_endgameOn4x4 => 'Finales en 4x4'; + + @override + String get taskTag_endgameTesuji => 'Tesuji de finales'; + + @override + String get taskTag_engulfingAtari => 'Engulfing atari'; + + @override + String get taskTag_escape => 'Escape'; + + @override + String get taskTag_escapeInOneMove => 'Escape en una jugada'; + + @override + String get taskTag_exploitShapeWeakness => 'Explota la debilidad de la forma'; + + @override + String get taskTag_eyeVsNoEye => 'Ojo contra sin-ojo'; + + @override + String get taskTag_fillNeutralPoints => 'Jugar puntos neutrales'; + + @override + String get taskTag_findTheRoot => 'Find the root'; + + @override + String get taskTag_firstLineBrilliantMove => + 'Jugada brillante en la primera línea'; + + @override + String get taskTag_flowerSix => 'Pececito'; + + @override + String get taskTag_goldenChickenStandingOnOneLeg => 'Posición del flamenco'; + + @override + String get taskTag_groupLiberties => 'Libertades de grupo'; + + @override + String get taskTag_groupsBase => 'Base de grupo'; + + @override + String get taskTag_hane => 'Hane'; + + @override + String get taskTag_increaseEyeSpace => 'Aumentar espacio vital'; + + @override + String get taskTag_increaseLiberties => 'Ganar libertades'; + + @override + String get taskTag_indirectAttack => 'Ataque indirecto'; + + @override + String get taskTag_influenceKeyPoints => 'Puntos clave de la influencia'; + + @override + String get taskTag_insideKill => 'Inside kill'; + + @override + String get taskTag_insideMoves => 'Inside moves'; + + @override + String get taskTag_interestingTasks => 'Problemas de interés especial'; + + @override + String get taskTag_internalLibertyShortage => 'Falta de libertades internas'; + + @override + String get taskTag_invadingTechnique => 'Técnicas de invasión'; + + @override + String get taskTag_invasion => 'Invasión'; + + @override + String get taskTag_jGroupAndSimilar => 'Grupo-J y similares'; + + @override + String get taskTag_josekiFundamentals => 'Fundamentos de joseki'; + + @override + String get taskTag_jump => 'Salto'; + + @override + String get taskTag_keepSente => 'Mantener sente'; + + @override + String get taskTag_killAfterCapture => 'Matar mediante captura'; + + @override + String get taskTag_killByEyePointPlacement => 'Kill by eye point placement'; + + @override + String get taskTag_knightsMove => 'Salto de caballo'; + + @override + String get taskTag_ko => 'Ko'; + + @override + String get taskTag_kosumiWedge => 'Cuña diagonal'; + + @override + String get taskTag_largeKnightsMove => 'Salto grande de caballo'; + + @override + String get taskTag_largeMoyoFight => 'Large moyo fight'; + + @override + String get taskTag_lifeAndDeath => 'Vida y muerte: general'; + + @override + String get taskTag_lifeAndDeathOn4x4 => 'Vida y muerte en 4x4'; + + @override + String get taskTag_lookForLeverage => 'Look for leverage'; + + @override + String get taskTag_looseLadder => 'Escalera larga'; + + @override + String get taskTag_lovesickCut => 'Lovesick cut'; + + @override + String get taskTag_makeEye => 'Hacer ojo'; + + @override + String get taskTag_makeEyeInOneStep => 'Hacer ojo en una jugada'; + + @override + String get taskTag_makeEyeInSente => 'Hacer ojo en sente'; + + @override + String get taskTag_makeKo => 'Hacer ko'; + + @override + String get taskTag_makeShape => 'Hacer forma'; + + @override + String get taskTag_middlegame => 'Medio-juego'; + + @override + String get taskTag_monkeyClimbingMountain => 'Monkey climbing the mountain'; + + @override + String get taskTag_mouseStealingOil => 'Mouse stealing oil'; + + @override + String get taskTag_moveOut => 'Move out'; + + @override + String get taskTag_moveTowardsEmptySpace => 'Move towards empty space'; + + @override + String get taskTag_multipleBrilliantMoves => 'Múltiples jugadas brillantes'; + + @override + String get taskTag_net => 'Red'; + + @override + String get taskTag_netCapture => 'Captura en red'; + + @override + String get taskTag_observeSubtleDifference => 'Observa la diferencia sutil'; + + @override + String get taskTag_occupyEncloseAndApproachCorner => + 'Ocupar, rodear y acercarse a las esquinas'; + + @override + String get taskTag_oneStoneTwoPurposes => 'Una jugada, dos objetivos'; + + @override + String get taskTag_opening => 'Apertura'; + + @override + String get taskTag_openingChoice => 'Elección de apertura'; + + @override + String get taskTag_openingFundamentals => 'Fundamentos de apertura'; + + @override + String get taskTag_orderOfEndgameMoves => 'Orden de jugadas de final'; + + @override + String get taskTag_orderOfMoves => 'Orden de jugadas'; + + @override + String get taskTag_orderOfMovesInKo => 'Orden de jugadas en un ko'; + + @override + String get taskTag_orioleCapturesButterfly => 'Oriole captures the butterfly'; + + @override + String get taskTag_pincer => 'Pinza'; + + @override + String get taskTag_placement => 'Placement'; + + @override + String get taskTag_plunderingTechnique => 'Plundering technique'; + + @override + String get taskTag_preventBambooJoint => 'Prevén la conección de bambú'; + + @override + String get taskTag_preventBridgingUnder => 'Prevent bridging under'; + + @override + String get taskTag_preventOpponentFromApproaching => + 'Prevent opponent from approaching'; + + @override + String get taskTag_probe => 'Jugada de prueba'; + + @override + String get taskTag_profitInSente => 'Ganancia en sente'; + + @override + String get taskTag_profitUsingLifeAndDeath => + 'Ganancia utilizando vida y muerte'; + + @override + String get taskTag_push => 'Push'; + + @override + String get taskTag_pyramidFour => 'Pirámide'; + + @override + String get taskTag_realEyeAndFalseEye => 'Ojo real contra ojo falso'; + + @override + String get taskTag_rectangularSix => 'Seis rectangulares'; + + @override + String get taskTag_reduceEyeSpace => 'Reducir espacio vital'; + + @override + String get taskTag_reduceLiberties => 'Reducir libertades'; + + @override + String get taskTag_reduction => 'Reducción'; + + @override + String get taskTag_runWeakGroup => 'Huída de grupo débil'; + + @override + String get taskTag_sabakiAndUtilizingInfluence => + 'Sabaki y el uso de influencia'; + + @override + String get taskTag_sacrifice => 'Sacrificio'; + + @override + String get taskTag_sacrificeAndSqueeze => 'Sacrifica y exprime'; + + @override + String get taskTag_sealIn => 'Sellar'; + + @override + String get taskTag_secondLine => 'Segunda línea'; + + @override + String get taskTag_seizeTheOpportunity => 'Aprovecha la oportunidad'; + + @override + String get taskTag_seki => 'Seki'; + + @override + String get taskTag_senteAndGote => 'Sente y gote'; + + @override + String get taskTag_settleShape => 'Asienta la forma'; + + @override + String get taskTag_settleShapeInSente => 'Asienta la forma en sente'; + + @override + String get taskTag_shape => 'Forma'; + + @override + String get taskTag_shapesVitalPoint => 'Punto vital de la forma'; + + @override + String get taskTag_side => 'Lado'; + + @override + String get taskTag_smallBoardEndgame => 'Finales en tableros pequeños'; + + @override + String get taskTag_snapback => 'Contracaptura'; + + @override + String get taskTag_solidConnection => 'Conección sólida'; + + @override + String get taskTag_solidExtension => 'Extensión sólida'; + + @override + String get taskTag_splitInOneMove => 'Separar en una jugada'; + + @override + String get taskTag_splittingMove => 'Jugada de separación'; + + @override + String get taskTag_squareFour => 'Cuatro cuadradas'; + + @override + String get taskTag_squeeze => 'Exprime'; + + @override + String get taskTag_standardCapturingRaces => 'Semeais estándares'; + + @override + String get taskTag_standardCornerAndSideEndgame => + 'Finales estándares en la esquina y el lado'; + + @override + String get taskTag_straightFour => 'Cuatro en línea'; + + @override + String get taskTag_straightThree => 'Tres en línea'; + + @override + String get taskTag_surroundTerritory => 'Rodear territorio'; + + @override + String get taskTag_symmetricShape => 'Forma simétrica'; + + @override + String get taskTag_techniqueForReinforcingGroups => + 'Técnicas para reforzar grupos'; + + @override + String get taskTag_techniqueForSecuringTerritory => + 'Técnicas para asegurar territorio'; + + @override + String get taskTag_textbookTasks => 'Problemas de libro'; + + @override + String get taskTag_thirdAndFourthLine => 'Tercera y cuarta línea'; + + @override + String get taskTag_threeEyesTwoActions => 'Tres ojos, dos jugadas'; + + @override + String get taskTag_threeSpaceExtensionFromTwoStones => + 'Three-space extension from two stones'; + + @override + String get taskTag_throwIn => 'Throw-in'; + + @override + String get taskTag_tigersMouth => 'Boca de tigre'; + + @override + String get taskTag_tombstoneSqueeze => 'Fantasma cabezón'; + + @override + String get taskTag_tripodGroupWithExtraLegAndSimilar => + 'Tripod group with extra leg and similar'; + + @override + String get taskTag_twoHaneGainOneLiberty => 'Double hane grows one liberty'; + + @override + String get taskTag_twoHeadedDragon => 'Dragón de dos cabezas'; + + @override + String get taskTag_twoSpaceExtension => 'Extensión de dos puntos'; + + @override + String get taskTag_typesOfKo => 'Tipos de ko'; + + @override + String get taskTag_underTheStones => 'Under the stones'; + + @override + String get taskTag_underneathAttachment => 'Underneath attachment'; + + @override + String get taskTag_urgentPointOfAFight => 'Punto urgente de una pelea'; + + @override + String get taskTag_urgentPoints => 'Puntos urgentes'; + + @override + String get taskTag_useConnectAndDie => 'Usa conecta y muere'; + + @override + String get taskTag_useCornerSpecialProperties => + 'Usa las propiedades especiales de la esquina'; + + @override + String get taskTag_useDescentToFirstLine => + 'Usa el descenso a la primera línea'; + + @override + String get taskTag_useInfluence => 'Usa la influencia'; + + @override + String get taskTag_useOpponentsLifeAndDeath => + 'Usa la vida y muerte del oponente'; + + @override + String get taskTag_useShortageOfLiberties => 'Usa la falta de libertades'; + + @override + String get taskTag_useSnapback => 'Usar contracaptura'; + + @override + String get taskTag_useSurroundingStones => 'Usar piedras alrededor'; + + @override + String get taskTag_vitalAndUselessStones => 'Piedras vitales e inútiles'; + + @override + String get taskTag_vitalPointForBothSides => 'Punto vital para ambos'; + + @override + String get taskTag_vitalPointForCapturingRace => 'Punto vital para semeais'; + + @override + String get taskTag_vitalPointForIncreasingLiberties => + 'Punto vital para aumentar libertades'; + + @override + String get taskTag_vitalPointForKill => 'Punto vital para matar'; + + @override + String get taskTag_vitalPointForLife => 'Punto vital para vivir'; + + @override + String get taskTag_vitalPointForReducingLiberties => + 'Punto vital para reducir libertades'; + + @override + String get taskTag_wedge => 'Cuña'; + + @override + String get taskTag_wedgingCapture => 'Captura usando la cuña'; + + @override + String get taskTimeout => 'Se acabó el tiempo'; + + @override + String get taskTypeAppreciation => 'Apreciación'; + + @override + String get taskTypeCapture => 'Capturar'; + + @override + String get taskTypeCaptureRace => 'Semeai'; + + @override + String get taskTypeEndgame => 'Finales'; + + @override + String get taskTypeJoseki => 'Joseki'; + + @override + String get taskTypeLifeAndDeath => 'Vida y muerte'; + + @override + String get taskTypeMiddlegame => 'Medio-juego'; + + @override + String get taskTypeOpening => 'Apertura'; + + @override + String get taskTypeTesuji => 'Tesuji'; + + @override + String get taskTypeTheory => 'Teoría'; + + @override + String get taskWrong => 'Incorrecto'; + + @override + String get tasksSolved => 'Problemas resueltos'; + + @override + String get test => 'Probar'; + + @override + String get theme => 'Tema'; + + @override + String get thick => 'Gruesa'; + + @override + String get timeFrenzy => 'Contrarreloj'; + + @override + String get timePerTask => 'Tiempo por problema'; + + @override + String get today => 'Hoy'; + + @override + String get tooltipAnalyzeWithAISensei => 'Analizar con AI Sensei'; + + @override + String get tooltipDownloadGame => 'Descargar partida'; + + @override + String get topic => 'Tema'; + + @override + String get topicExam => 'Examen temático'; + + @override + String get topics => 'Temas'; + + @override + String get train => 'Entrenar'; + + @override + String get trainingAvgTimePerTask => 'Tiempo promedio por problema'; + + @override + String get trainingFailed => 'No aprobado'; + + @override + String get trainingMistakes => 'Errores'; + + @override + String get trainingPassed => 'Aprobado'; + + @override + String get trainingTotalTime => 'Tiempo total'; + + @override + String get tryCustomMoves => 'Probar otras jugadas'; + + @override + String get tygemDesc => + 'El servidor más popular de Corea y uno de los más populares del mundo.'; + + @override + String get tygemName => 'Tygem Baduk'; + + @override + String get type => 'Tipo'; + + @override + String get ui => 'Interfaz'; + + @override + String get userInfo => 'Perfil de usuario'; + + @override + String get username => 'Nombre de usuario'; + + @override + String get voice => 'Voz'; + + @override + String get week => 'Semana'; + + @override + String get white => 'Blancas'; + + @override + String get yes => 'Sí'; +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart new file mode 100644 index 00000000..a347fa23 --- /dev/null +++ b/lib/l10n/app_localizations_ru.dart @@ -0,0 +1,1347 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get about => 'О приложении'; + + @override + String get accuracy => 'Точность'; + + @override + String get aiReferee => 'ИИ-судья'; + + @override + String get aiSensei => 'AI Sensei'; + + @override + String get alwaysBlackToPlay => 'Всегда ход чёрных'; + + @override + String get alwaysBlackToPlayDesc => + 'Устанавливать все задачи как ход чёрных, чтобы избежать путаницы'; + + @override + String get appearance => 'Appearance'; + + @override + String get autoCounting => 'Автоподсчёт'; + + @override + String get autoMatch => 'Auto-Match'; + + @override + String get behaviour => 'Behaviour'; + + @override + String get bestResult => 'Лучший результат'; + + @override + String get black => 'Чёрные'; + + @override + String get board => 'Доска'; + + @override + String get boardSize => 'Размер доски'; + + @override + String get boardTheme => 'Тема доски'; + + @override + String get byRank => 'По рангу'; + + @override + String get cancel => 'Отмена'; + + @override + String get captures => 'Захваты'; + + @override + String get collectStats => 'Collect statistics'; + + @override + String get collections => 'Коллекции'; + + @override + String get confirm => 'Confirm'; + + @override + String get confirmBoardSize => 'Confirm board size'; + + @override + String get confirmBoardSizeDesc => + 'Boards this size or larger require move confirmation'; + + @override + String get confirmMoves => 'Confirm moves'; + + @override + String get confirmMovesDesc => + 'Double-tap to confirm moves on large boards to avoid misclicks'; + + @override + String get continue_ => 'Продолжить'; + + @override + String get copyTaskLink => 'Копировать ссылку на задачу'; + + @override + String get customExam => 'Custom exam'; + + @override + String get dark => 'Тёмная'; + + @override + String get dontShowAgain => 'Больше не показывать'; + + @override + String get download => 'Скачать'; + + @override + String get edgeLine => 'Линия края'; + + @override + String get endgameExam => 'Endgame exam'; + + @override + String get enterTaskLink => 'Введите ссылку на задачу'; + + @override + String get errCannotBeEmpty => 'Cannot be empty'; + + @override + String get errFailedToDownloadGame => 'Не удалось скачать партию'; + + @override + String get errFailedToLoadGameList => + 'Не удалось загрузить список партий. Пожалуйста, попробуйте снова.'; + + @override + String get errFailedToUploadGameToAISensei => + 'Не удалось загрузить партию в AI Sensei'; + + @override + String get errIncorrectUsernameOrPassword => + 'Неверное имя пользователя или пароль'; + + @override + String errMustBeAtLeast(num n) { + return 'Must be at least $n'; + } + + @override + String errMustBeAtMost(num n) { + return 'Must be at most $n'; + } + + @override + String get errMustBeInteger => 'Must be an integer'; + + @override + String get exit => 'Exit'; + + @override + String get exitTryMode => 'Exit try mode'; + + @override + String get find => 'Найти'; + + @override + String get findTask => 'Найти задачу'; + + @override + String get forceCounting => 'Force counting'; + + @override + String get foxwqDesc => 'Самый популярный сервер в Китае и в мире.'; + + @override + String get foxwqName => 'Фокс Вэйци'; + + @override + String get gameInfo => 'Информация о партии'; + + @override + String get gameRecord => 'Запись партии'; + + @override + String get gradingExam => 'Grading exam'; + + @override + String get handicap => 'Фора'; + + @override + String get help => 'Помощь'; + + @override + String get helpDialogCollections => + 'Collections are classic, curated sets of high-quality tasks which hold special value together as a training resource.\n\nThe main goal is to solve a collection with a high success rate. A secondary goal is to solve it as fast as possible.\n\nTo start or continue solving a collection, slide left on the collection tile while in portrait mode or click the Start/Continue buttons while in landscape mode.'; + + @override + String get helpDialogEndgameExam => + '- Endgame exams are sets of 10 endgame tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.'; + + @override + String get helpDialogGradingExam => + '- Grading exams are sets of 10 tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.'; + + @override + String get helpDialogRankedMode => + '- Solve tasks without a time limit.\n\n- Task difficulty increases according to how fast you solve them.\n\n- Focus on solving correctly and reach the highest rank possible.'; + + @override + String get helpDialogTimeFrenzy => + '- You have 3 minutes to solve as many tasks as possible.\n\n- Tasks get increasingly difficult as you solve them.\n\n- If you make 3 mistakes, you are out.'; + + @override + String get home => 'Главная'; + + @override + String get komi => 'Коми'; + + @override + String get language => 'Язык'; + + @override + String get leave => 'Leave'; + + @override + String get light => 'Светлая'; + + @override + String get login => 'Войти'; + + @override + String get logout => 'Выйти'; + + @override + String get long => 'Long'; + + @override + String mMinutes(int m) { + return '$m мин'; + } + + @override + String get maxNumberOfMistakes => 'Максимальное количество ошибок'; + + @override + String get maxRank => 'Макс. ранг'; + + @override + String get medium => 'Medium'; + + @override + String get minRank => 'Мин. ранг'; + + @override + String get minutes => 'Минуты'; + + @override + String get month => 'Month'; + + @override + String get msgCannotUseAIRefereeYet => 'ИИ-судья ещё нельзя использовать'; + + @override + String get msgCannotUseForcedCountingYet => + 'Forced counting cannot be used yet'; + + @override + String get msgConfirmDeleteCollectionProgress => + 'Are you sure that you want to delete the previous attempt?'; + + @override + String get msgConfirmResignation => 'Вы уверены, что хотите сдаться?'; + + @override + String msgConfirmStopEvent(String event) { + return 'Вы уверены, что хотите остановить $event?'; + } + + @override + String get msgDownloadingGame => 'Скачивание партии'; + + @override + String msgGameSavedTo(String path) { + return 'Партия сохранена в $path'; + } + + @override + String get msgPleaseWaitForYourTurn => 'Пожалуйста, дождитесь своего хода'; + + @override + String get msgSearchingForGame => 'Поиск партии...'; + + @override + String get msgTaskLinkCopied => 'Ссылка на задачу скопирована.'; + + @override + String get msgWaitingForOpponentsDecision => + 'Ожидание решения вашего оппонента...'; + + @override + String get msgYouCannotPass => 'Вы не можете пасовать'; + + @override + String get msgYourOpponentDisagreesWithCountingResult => + 'Ваш оппонент не согласен с результатом подсчёта'; + + @override + String get msgYourOpponentRefusesToCount => + 'Ваш оппонент отказывается от подсчёта'; + + @override + String get msgYourOpponentRequestsAutomaticCounting => + 'Ваш оппонент запрашивает автоматический подсчёт. Вы согласны?'; + + @override + String get myGames => 'Мои партии'; + + @override + String get myMistakes => 'Мои ошибки'; + + @override + String nTasks(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString задач', + one: '1 задача', + zero: 'Нет задач', + ); + return '$_temp0'; + } + + @override + String nTasksAvailable(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString задач доступно', + one: '1 задача доступна', + zero: 'Нет доступных задач', + ); + return '$_temp0'; + } + + @override + String get newBestResult => 'Новый рекорд!'; + + @override + String get no => 'Нет'; + + @override + String get none => 'Нет'; + + @override + String get numberOfTasks => 'Количество задач'; + + @override + String nxnBoardSize(int n) { + return '$n×$n'; + } + + @override + String get ogsDesc => + 'Главная онлайн-платформа для игры в Го с турнирами, анализом от ИИ и активным сообществом.'; + + @override + String get ogsName => 'Online Go Server'; + + @override + String get ok => 'ОК'; + + @override + String get pass => 'Пас'; + + @override + String get password => 'Пароль'; + + @override + String get play => 'Играть'; + + @override + String get promotionRequirements => 'Promotion requirements'; + + @override + String pxsByoyomi(int p, int s) { + return '$p×$sс'; + } + + @override + String get rank => 'Ранг'; + + @override + String get rankedMode => 'Ranked mode'; + + @override + String get recentRecord => 'Recent record'; + + @override + String get register => 'Зарегистрироваться'; + + @override + String get resign => 'Сдаться'; + + @override + String get responseDelay => 'Response delay'; + + @override + String get responseDelayDesc => + 'Duration of the delay before the response appears while solving tasks'; + + @override + String get responseDelayLong => 'Длинная'; + + @override + String get responseDelayMedium => 'Средняя'; + + @override + String get responseDelayNone => 'Нет'; + + @override + String get responseDelayShort => 'Короткая'; + + @override + String get result => 'Результат'; + + @override + String get rules => 'Правила'; + + @override + String get rulesChinese => 'Китайские'; + + @override + String get rulesJapanese => 'Японские'; + + @override + String get rulesKorean => 'Корейские'; + + @override + String get save => 'Сохранить'; + + @override + String get saveSGF => 'Сохранить SGF'; + + @override + String get seconds => 'Секунды'; + + @override + String get settings => 'Настройки'; + + @override + String get short => 'Short'; + + @override + String get showCoordinates => 'Показывать координаты'; + + @override + String get simple => 'Simple'; + + @override + String get sortModeDifficult => 'Сложные'; + + @override + String get sortModeRecent => 'Недавние'; + + @override + String get sound => 'Звук'; + + @override + String get start => 'Начать'; + + @override + String get statistics => 'Статистика'; + + @override + String get statsDateColumn => 'Дата'; + + @override + String get statsDurationColumn => 'Время'; + + @override + String get statsTimeColumn => 'Время'; + + @override + String get stoneShadows => 'Тени камней'; + + @override + String get stones => 'Камни'; + + @override + String get subtopic => 'Подтема'; + + @override + String get system => 'Система'; + + @override + String get task => 'Задача'; + + @override + String get taskCorrect => 'Верно'; + + @override + String get taskNext => 'Следующая'; + + @override + String get taskRedo => 'Переделать'; + + @override + String get taskSource => 'Task source'; + + @override + String get taskSourceFromMyMistakes => 'Из моих ошибок'; + + @override + String get taskSourceFromTaskTopic => 'Из темы задач'; + + @override + String get taskSourceFromTaskTypes => 'Из типов задач'; + + @override + String get taskTag_afterJoseki => 'После джосеки'; + + @override + String get taskTag_aiOpening => 'AI opening'; + + @override + String get taskTag_aiVariations => 'AI variations'; + + @override + String get taskTag_attack => 'Атака'; + + @override + String get taskTag_attackAndDefenseInKo => 'Attack and defense in a ko'; + + @override + String get taskTag_attackAndDefenseOfCuts => 'Атака и защита разрезаний'; + + @override + String get taskTag_attackAndDefenseOfInvadingStones => + 'Attack and defense of invading stones'; + + @override + String get taskTag_avoidKo => 'Избежать ко'; + + @override + String get taskTag_avoidMakingDeadShape => 'Avoid making dead shape'; + + @override + String get taskTag_avoidTrap => 'Avoid trap'; + + @override + String get taskTag_basicEndgame => 'Endgame: basic'; + + @override + String get taskTag_basicLifeAndDeath => 'Базовые задачи на жизнь и смерть'; + + @override + String get taskTag_basicMoves => 'Базовые ходы'; + + @override + String get taskTag_basicTesuji => 'Tesuji'; + + @override + String get taskTag_beginner => 'Базовые приемы'; + + @override + String get taskTag_bend => 'Bend'; + + @override + String get taskTag_bentFour => 'Изогнутая четверка'; + + @override + String get taskTag_bentFourInTheCorner => 'Изогнутая четверка в углу'; + + @override + String get taskTag_bentThree => 'Изогнутая тройка'; + + @override + String get taskTag_bigEyeLiberties => 'Big eye\'s liberties'; + + @override + String get taskTag_bigEyeVsSmallEye => + 'Группа с большим глазом против группы с маленьким глазом'; + + @override + String get taskTag_bigPoints => 'Большой пункт'; + + @override + String get taskTag_blindSpot => 'Слепое пятно'; + + @override + String get taskTag_breakEye => 'Разрушение глаза'; + + @override + String get taskTag_breakEyeInOneStep => 'Разрушение глаза в один ход'; + + @override + String get taskTag_breakEyeInSente => 'Разрушение глаза в сенте'; + + @override + String get taskTag_breakOut => 'Break out'; + + @override + String get taskTag_breakPoints => 'Break points'; + + @override + String get taskTag_breakShape => 'Разрушить форму'; + + @override + String get taskTag_bridgeUnder => 'Bridge under'; + + @override + String get taskTag_brilliantSequence => 'Brilliant sequence'; + + @override + String get taskTag_bulkyFive => 'Машинка'; + + @override + String get taskTag_bump => 'Удар'; + + @override + String get taskTag_captureBySnapback => 'Захват в защелку'; + + @override + String get taskTag_captureInLadder => 'Захват лестницы'; + + @override + String get taskTag_captureInOneMove => 'Захват в один ход'; + + @override + String get taskTag_captureOnTheSide => 'Захват на стороне'; + + @override + String get taskTag_captureToLive => 'Capture to live'; + + @override + String get taskTag_captureTwoRecaptureOne => 'Захвати два - верни один'; + + @override + String get taskTag_capturingRace => 'Семеай'; + + @override + String get taskTag_capturingTechniques => 'Техника захвата'; + + @override + String get taskTag_carpentersSquareAndSimilar => + 'Плотницкий квадрат и похожее'; + + @override + String get taskTag_chooseTheFight => 'Выбрать бой'; + + @override + String get taskTag_clamp => 'Зажим'; + + @override + String get taskTag_clampCapture => 'Clamp capture'; + + @override + String get taskTag_closeInCapture => 'Closing-in capture'; + + @override + String get taskTag_combination => 'Комбинация'; + + @override + String get taskTag_commonLifeAndDeath => 'Life & death: common shapes'; + + @override + String get taskTag_compareSize => 'Compare size'; + + @override + String get taskTag_compareValue => 'Compare value'; + + @override + String get taskTag_completeKoToSecureEndgameAdvantage => + 'Complete ko to secure endgame advantage'; + + @override + String get taskTag_compositeProblems => 'Composite tasks'; + + @override + String get taskTag_comprehensiveTasks => 'Comprehensive tasks'; + + @override + String get taskTag_connect => 'Соединение'; + + @override + String get taskTag_connectAndDie => 'Connect and die'; + + @override + String get taskTag_connectInOneMove => 'Соединение в один ход'; + + @override + String get taskTag_contactFightTesuji => 'Техника ближнего боя'; + + @override + String get taskTag_contactPlay => 'Contact play'; + + @override + String get taskTag_corner => 'Угол'; + + @override + String get taskTag_cornerIsGoldSideIsSilverCenterIsGrass => + 'Угол - золото, сторона - серебро, центр - трава.'; + + @override + String get taskTag_counter => 'Counter'; + + @override + String get taskTag_counterAttack => 'Контратака'; + + @override + String get taskTag_cranesNest => 'Crane\'s nest'; + + @override + String get taskTag_crawl => 'Crawl'; + + @override + String get taskTag_createShortageOfLiberties => 'Создание нехватки дыханий'; + + @override + String get taskTag_crossedFive => 'Crossed five'; + + @override + String get taskTag_cut => 'Разрезание'; + + @override + String get taskTag_cut2 => 'Разрезание'; + + @override + String get taskTag_cutAcross => 'Cut across'; + + @override + String get taskTag_defendFromInvasion => 'Защита от вторжения'; + + @override + String get taskTag_defendPoints => 'Defend points'; + + @override + String get taskTag_defendWeakPoint => 'Defend weak point'; + + @override + String get taskTag_descent => 'Descent'; + + @override + String get taskTag_diagonal => 'Косуми'; + + @override + String get taskTag_directionOfCapture => 'Направление захвата'; + + @override + String get taskTag_directionOfEscape => 'Направление побега'; + + @override + String get taskTag_directionOfPlay => 'Направление игры'; + + @override + String get taskTag_doNotUnderestimateOpponent => + 'Не стоит недооценивать противника'; + + @override + String get taskTag_doubleAtari => 'Двойное атари'; + + @override + String get taskTag_doubleCapture => 'Double capture'; + + @override + String get taskTag_doubleKo => 'Двойное ко'; + + @override + String get taskTag_doubleSenteEndgame => 'Двойное сенте в йосе'; + + @override + String get taskTag_doubleSnapback => 'Двойная защелка'; + + @override + String get taskTag_endgame => 'Endgame: general'; + + @override + String get taskTag_endgameFundamentals => 'Endgame fundamentals'; + + @override + String get taskTag_endgameIn5x5 => '5х5 йосе'; + + @override + String get taskTag_endgameOn4x4 => '4х4 йосе'; + + @override + String get taskTag_endgameTesuji => 'Йосе-тесуджи'; + + @override + String get taskTag_engulfingAtari => 'Engulfing atari'; + + @override + String get taskTag_escape => 'Побег'; + + @override + String get taskTag_escapeInOneMove => 'Побег в один ход'; + + @override + String get taskTag_exploitShapeWeakness => 'Exploit shape weakness'; + + @override + String get taskTag_eyeVsNoEye => 'Группа с глазом против группы без глаза'; + + @override + String get taskTag_fillNeutralPoints => 'Заполнение нейтральных пунктов'; + + @override + String get taskTag_findTheRoot => 'Find the root'; + + @override + String get taskTag_firstLineBrilliantMove => 'Блестящий ход на 1-й линии'; + + @override + String get taskTag_flowerSix => 'Flower six'; + + @override + String get taskTag_goldenChickenStandingOnOneLeg => + 'Golden rooster standing on one leg'; + + @override + String get taskTag_groupLiberties => 'Group liberties'; + + @override + String get taskTag_groupsBase => 'Group\'s base'; + + @override + String get taskTag_hane => 'Хане'; + + @override + String get taskTag_increaseEyeSpace => 'Увеличить глазное пространство'; + + @override + String get taskTag_increaseLiberties => 'Наращивание дыханий'; + + @override + String get taskTag_indirectAttack => 'Косвенная атака'; + + @override + String get taskTag_influenceKeyPoints => 'Influence key points'; + + @override + String get taskTag_insideKill => 'Inside kill'; + + @override + String get taskTag_insideMoves => 'Inside moves'; + + @override + String get taskTag_interestingTasks => 'Интересные задания'; + + @override + String get taskTag_internalLibertyShortage => 'Internal liberty shortage'; + + @override + String get taskTag_invadingTechnique => 'Invading technique'; + + @override + String get taskTag_invasion => 'Вторжение'; + + @override + String get taskTag_jGroupAndSimilar => 'J-group and similar'; + + @override + String get taskTag_josekiFundamentals => 'Joseki fundamentals'; + + @override + String get taskTag_jump => 'Прыжок'; + + @override + String get taskTag_keepSente => 'Сохранить сенте'; + + @override + String get taskTag_killAfterCapture => 'Убийство после захвата'; + + @override + String get taskTag_killByEyePointPlacement => 'Kill by eye point placement'; + + @override + String get taskTag_knightsMove => 'Knight\'s move'; + + @override + String get taskTag_ko => 'Ко'; + + @override + String get taskTag_kosumiWedge => 'Kosumi wedge'; + + @override + String get taskTag_largeKnightsMove => 'Large knight move'; + + @override + String get taskTag_largeMoyoFight => 'Large moyo fight'; + + @override + String get taskTag_lifeAndDeath => 'Жизнь и смерть'; + + @override + String get taskTag_lifeAndDeathOn4x4 => '4х4 жизнь и смерть'; + + @override + String get taskTag_lookForLeverage => 'Look for leverage'; + + @override + String get taskTag_looseLadder => 'Loose ladder'; + + @override + String get taskTag_lovesickCut => 'Lovesick cut'; + + @override + String get taskTag_makeEye => 'Построение глаза'; + + @override + String get taskTag_makeEyeInOneStep => 'Построение глаза в один ход'; + + @override + String get taskTag_makeEyeInSente => 'Построение глаза в сенте'; + + @override + String get taskTag_makeKo => 'Начать ко'; + + @override + String get taskTag_makeShape => 'Построить форму'; + + @override + String get taskTag_middlegame => 'Середина игры'; + + @override + String get taskTag_monkeyClimbingMountain => 'Monkey climbing the mountain'; + + @override + String get taskTag_mouseStealingOil => 'Mouse stealing oil'; + + @override + String get taskTag_moveOut => 'Убегание'; + + @override + String get taskTag_moveTowardsEmptySpace => + 'Двигаться к пустому пространству'; + + @override + String get taskTag_multipleBrilliantMoves => 'Multiple brilliant moves'; + + @override + String get taskTag_net => 'Net'; + + @override + String get taskTag_netCapture => 'Net capture'; + + @override + String get taskTag_observeSubtleDifference => 'Заметить тонкую разницу'; + + @override + String get taskTag_occupyEncloseAndApproachCorner => + 'Occupy, enclose and approach corners'; + + @override + String get taskTag_oneStoneTwoPurposes => 'Один ход, две цели'; + + @override + String get taskTag_opening => 'Фусеки'; + + @override + String get taskTag_openingChoice => 'Opening choice'; + + @override + String get taskTag_openingFundamentals => 'Opening fundamentals'; + + @override + String get taskTag_orderOfEndgameMoves => 'Порядок ходов в йосе'; + + @override + String get taskTag_orderOfMoves => 'Порядок ходов'; + + @override + String get taskTag_orderOfMovesInKo => 'Порядок ходов в ко'; + + @override + String get taskTag_orioleCapturesButterfly => 'Oriole captures the butterfly'; + + @override + String get taskTag_pincer => 'Pincer'; + + @override + String get taskTag_placement => 'Placement'; + + @override + String get taskTag_plunderingTechnique => 'Plundering technique'; + + @override + String get taskTag_preventBambooJoint => 'Prevent the bamboo joint'; + + @override + String get taskTag_preventBridgingUnder => 'Prevent bridging under'; + + @override + String get taskTag_preventOpponentFromApproaching => + 'Prevent opponent from approaching'; + + @override + String get taskTag_probe => 'Probe'; + + @override + String get taskTag_profitInSente => 'Прибыль в сенте'; + + @override + String get taskTag_profitUsingLifeAndDeath => 'Profit using life and death'; + + @override + String get taskTag_push => 'Push'; + + @override + String get taskTag_pyramidFour => 'Пирамида'; + + @override + String get taskTag_realEyeAndFalseEye => 'Глаз против ложного глаза'; + + @override + String get taskTag_rectangularSix => 'Rectangular six'; + + @override + String get taskTag_reduceEyeSpace => 'Уменьшить глазное пространство'; + + @override + String get taskTag_reduceLiberties => 'Сокращение дыханий'; + + @override + String get taskTag_reduction => 'Reduction'; + + @override + String get taskTag_runWeakGroup => 'Побег слабой группы'; + + @override + String get taskTag_sabakiAndUtilizingInfluence => + 'Sabaki and utilizing influence'; + + @override + String get taskTag_sacrifice => 'Жертва'; + + @override + String get taskTag_sacrificeAndSqueeze => 'Sacrifice and squeeze'; + + @override + String get taskTag_sealIn => 'Seal in'; + + @override + String get taskTag_secondLine => '2-я линия'; + + @override + String get taskTag_seizeTheOpportunity => 'Seize the opportunity'; + + @override + String get taskTag_seki => 'Секи'; + + @override + String get taskTag_senteAndGote => 'Сенте и готе'; + + @override + String get taskTag_settleShape => 'Settle shape'; + + @override + String get taskTag_settleShapeInSente => 'Settle shape in sente'; + + @override + String get taskTag_shape => 'Форма'; + + @override + String get taskTag_shapesVitalPoint => 'Жизненно важный пункт формы'; + + @override + String get taskTag_side => 'Сторона'; + + @override + String get taskTag_smallBoardEndgame => 'Йосе на маленькой доске'; + + @override + String get taskTag_snapback => 'Защелка'; + + @override + String get taskTag_solidConnection => 'Solid connection'; + + @override + String get taskTag_solidExtension => 'Solid extension'; + + @override + String get taskTag_splitInOneMove => 'Разделение в один ход'; + + @override + String get taskTag_splittingMove => 'Splitting move'; + + @override + String get taskTag_squareFour => 'Square four'; + + @override + String get taskTag_squeeze => 'Сжатие'; + + @override + String get taskTag_standardCapturingRaces => 'Standard capturing races'; + + @override + String get taskTag_standardCornerAndSideEndgame => + 'Стандартное йосе в углу и на стороне'; + + @override + String get taskTag_straightFour => 'Четыре в ряд'; + + @override + String get taskTag_straightThree => 'Три в ряд'; + + @override + String get taskTag_surroundTerritory => 'Surround territory'; + + @override + String get taskTag_symmetricShape => 'Симметричная форма'; + + @override + String get taskTag_techniqueForReinforcingGroups => 'Техника усиления'; + + @override + String get taskTag_techniqueForSecuringTerritory => + 'Техника защиты территории'; + + @override + String get taskTag_textbookTasks => 'Задачи из книг'; + + @override + String get taskTag_thirdAndFourthLine => 'Third and fourth line'; + + @override + String get taskTag_threeEyesTwoActions => 'Three eyes, two actions'; + + @override + String get taskTag_threeSpaceExtensionFromTwoStones => + 'Three-space extension from two stones'; + + @override + String get taskTag_throwIn => 'Throw-in'; + + @override + String get taskTag_tigersMouth => 'Пасть тигра'; + + @override + String get taskTag_tombstoneSqueeze => 'Сжатие надгробной плиты'; + + @override + String get taskTag_tripodGroupWithExtraLegAndSimilar => + 'Tripod group with extra leg and similar'; + + @override + String get taskTag_twoHaneGainOneLiberty => 'Double hane grows one liberty'; + + @override + String get taskTag_twoHeadedDragon => 'Двухглавый дракон'; + + @override + String get taskTag_twoSpaceExtension => 'Two-space extension'; + + @override + String get taskTag_typesOfKo => 'Types of ko'; + + @override + String get taskTag_underTheStones => 'Under the stones'; + + @override + String get taskTag_underneathAttachment => 'Underneath attachment'; + + @override + String get taskTag_urgentPointOfAFight => 'Urgent point of a fight'; + + @override + String get taskTag_urgentPoints => 'Срочный пункт'; + + @override + String get taskTag_useConnectAndDie => 'Use connect and die'; + + @override + String get taskTag_useCornerSpecialProperties => + 'Использование особых свойств угла'; + + @override + String get taskTag_useDescentToFirstLine => 'Use descent to first line'; + + @override + String get taskTag_useInfluence => 'Использовать влияние'; + + @override + String get taskTag_useOpponentsLifeAndDeath => + 'Use opponent\'s life and death'; + + @override + String get taskTag_useShortageOfLiberties => 'Использовать нехватку дыханий'; + + @override + String get taskTag_useSnapback => 'Использование защелки'; + + @override + String get taskTag_useSurroundingStones => 'Использование внешних камней'; + + @override + String get taskTag_vitalAndUselessStones => 'Важные и бесполезные камни'; + + @override + String get taskTag_vitalPointForBothSides => 'Важный пункт для обеих сторон'; + + @override + String get taskTag_vitalPointForCapturingRace => + 'Vital point for capturing race'; + + @override + String get taskTag_vitalPointForIncreasingLiberties => + 'Vital point for increasing liberties'; + + @override + String get taskTag_vitalPointForKill => 'Жизненно важный пункт для убийства'; + + @override + String get taskTag_vitalPointForLife => 'Жизненно важный пункт для жизни'; + + @override + String get taskTag_vitalPointForReducingLiberties => + 'Важная точка для сокращения дыханий'; + + @override + String get taskTag_wedge => 'Клин'; + + @override + String get taskTag_wedgingCapture => 'Wedging capture'; + + @override + String get taskTimeout => 'Время вышло'; + + @override + String get taskTypeAppreciation => 'Appreciation'; + + @override + String get taskTypeCapture => 'Capture stones'; + + @override + String get taskTypeCaptureRace => 'Семеай'; + + @override + String get taskTypeEndgame => 'Йосе'; + + @override + String get taskTypeJoseki => 'Джосеки'; + + @override + String get taskTypeLifeAndDeath => 'Жизнь и смерть'; + + @override + String get taskTypeMiddlegame => 'Середина игры'; + + @override + String get taskTypeOpening => 'Фусеки'; + + @override + String get taskTypeTesuji => 'Тесуджи'; + + @override + String get taskTypeTheory => 'Теория'; + + @override + String get taskWrong => 'Неверно'; + + @override + String get tasksSolved => 'Задач решено'; + + @override + String get test => 'Тест'; + + @override + String get theme => 'Тема'; + + @override + String get thick => 'Thick'; + + @override + String get timeFrenzy => 'Time frenzy'; + + @override + String get timePerTask => 'Time per task'; + + @override + String get today => 'Сегодня'; + + @override + String get tooltipAnalyzeWithAISensei => 'Анализировать с AI Sensei'; + + @override + String get tooltipDownloadGame => 'Скачать партию'; + + @override + String get topic => 'Тема'; + + @override + String get topicExam => 'Topic exam'; + + @override + String get topics => 'Темы'; + + @override + String get train => 'Тренироваться'; + + @override + String get trainingAvgTimePerTask => 'Avg time per task'; + + @override + String get trainingFailed => 'Failed'; + + @override + String get trainingMistakes => 'Ошибки'; + + @override + String get trainingPassed => 'Passed'; + + @override + String get trainingTotalTime => 'Общее время'; + + @override + String get tryCustomMoves => 'Попробовать свои ходы'; + + @override + String get tygemDesc => + 'Самый популярный сервер в Корее и один из самых популярных в мире.'; + + @override + String get tygemName => 'Tygem Baduk'; + + @override + String get type => 'Тип'; + + @override + String get ui => 'Интерфейс'; + + @override + String get userInfo => 'Информация о пользователе'; + + @override + String get username => 'Имя пользователя'; + + @override + String get voice => 'Голос'; + + @override + String get week => 'Неделя'; + + @override + String get white => 'Белые'; + + @override + String get yes => 'Да'; +} diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart new file mode 100644 index 00000000..eee510eb --- /dev/null +++ b/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,1322 @@ +// 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 about => '关于'; + + @override + String get accuracy => 'Accuracy'; + + @override + String get aiReferee => 'AI referee'; + + @override + String get aiSensei => 'AI Sensei'; + + @override + String get alwaysBlackToPlay => '总是执黑先行'; + + @override + String get alwaysBlackToPlayDesc => '永远执黑先行,防止混淆'; + + @override + String get appearance => '外观'; + + @override + String get autoCounting => 'Auto counting'; + + @override + String get autoMatch => 'Auto-Match'; + + @override + String get behaviour => '操作'; + + @override + String get bestResult => '最优结果'; + + @override + String get black => 'Black'; + + @override + String get board => '棋盘'; + + @override + String get boardSize => '棋盘颜色'; + + @override + String get boardTheme => '棋盘主题'; + + @override + String get byRank => 'By rank'; + + @override + String get cancel => '取消'; + + @override + String get captures => 'Captures'; + + @override + String get collectStats => 'Collect statistics'; + + @override + String get collections => 'Collections'; + + @override + String get confirm => '确认'; + + @override + String get confirmBoardSize => '确认棋盘大小'; + + @override + String get confirmBoardSizeDesc => + 'Boards this size or larger require move confirmation'; + + @override + String get confirmMoves => '确定落子'; + + @override + String get confirmMovesDesc => '在较大的棋盘上双击以确认落子,避免误操作'; + + @override + String get continue_ => '继续'; + + @override + String get copyTaskLink => 'Copy task link'; + + @override + String get customExam => 'Custom exam'; + + @override + String get dark => 'Dark'; + + @override + String get dontShowAgain => '不再显示'; + + @override + String get download => '下载'; + + @override + String get edgeLine => '棋盘边缘'; + + @override + String get endgameExam => 'Endgame exam'; + + @override + String get enterTaskLink => 'Enter the task link'; + + @override + String get errCannotBeEmpty => 'Cannot be empty'; + + @override + String get errFailedToDownloadGame => 'Failed to download game'; + + @override + String get errFailedToLoadGameList => + 'Failed to load game list. Please try again.'; + + @override + String get errFailedToUploadGameToAISensei => + 'Failed to upload game to AI Sensei'; + + @override + String get errIncorrectUsernameOrPassword => 'Incorrect username or password'; + + @override + String errMustBeAtLeast(num n) { + return 'Must be at least $n'; + } + + @override + String errMustBeAtMost(num n) { + return 'Must be at most $n'; + } + + @override + String get errMustBeInteger => 'Must be an integer'; + + @override + String get exit => '退出'; + + @override + String get exitTryMode => 'Exit try mode'; + + @override + String get find => '查找'; + + @override + String get findTask => 'Find task'; + + @override + String get forceCounting => 'Force counting'; + + @override + String get foxwqDesc => 'The most popular server in China and the world.'; + + @override + String get foxwqName => 'Fox Weiqi'; + + @override + String get gameInfo => 'Game info'; + + @override + String get gameRecord => 'Game record'; + + @override + String get gradingExam => 'Grading exam'; + + @override + String get handicap => '让子数'; + + @override + String get help => '帮助'; + + @override + String get helpDialogCollections => + 'Collections are classic, curated sets of high-quality tasks which hold special value together as a training resource.\n\nThe main goal is to solve a collection with a high success rate. A secondary goal is to solve it as fast as possible.\n\nTo start or continue solving a collection, slide left on the collection tile while in portrait mode or click the Start/Continue buttons while in landscape mode.'; + + @override + String get helpDialogEndgameExam => + '- Endgame exams are sets of 10 endgame tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.'; + + @override + String get helpDialogGradingExam => + '- Grading exams are sets of 10 tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.'; + + @override + String get helpDialogRankedMode => + '- Solve tasks without a time limit.\n\n- Task difficulty increases according to how fast you solve them.\n\n- Focus on solving correctly and reach the highest rank possible.'; + + @override + String get helpDialogTimeFrenzy => + '- You have 3 minutes to solve as many tasks as possible.\n\n- Tasks get increasingly difficult as you solve them.\n\n- If you make 3 mistakes, you are out.'; + + @override + String get home => '主界面'; + + @override + String get komi => 'Komi'; + + @override + String get language => '语言'; + + @override + String get leave => 'Leave'; + + @override + String get light => '浅色'; + + @override + String get login => '登录'; + + @override + String get logout => '退出'; + + @override + String get long => 'Long'; + + @override + String mMinutes(int m) { + return '${m}min'; + } + + @override + String get maxNumberOfMistakes => 'Maximum number of mistakes'; + + @override + String get maxRank => 'Max rank'; + + @override + String get medium => 'Medium'; + + @override + String get minRank => 'Min rank'; + + @override + String get minutes => 'Minutes'; + + @override + String get month => 'Month'; + + @override + String get msgCannotUseAIRefereeYet => 'AI referee cannot be used yet'; + + @override + String get msgCannotUseForcedCountingYet => + 'Forced counting cannot be used yet'; + + @override + String get msgConfirmDeleteCollectionProgress => + 'Are you sure that you want to delete the previous attempt?'; + + @override + String get msgConfirmResignation => 'Are you sure that you want to resign?'; + + @override + String msgConfirmStopEvent(String event) { + return 'Are you sure that you want to stop the $event?'; + } + + @override + String get msgDownloadingGame => 'Downloading game'; + + @override + String msgGameSavedTo(String path) { + return 'Game saved to $path'; + } + + @override + String get msgPleaseWaitForYourTurn => 'Please, wait for your turn'; + + @override + String get msgSearchingForGame => 'Searching for a game...'; + + @override + String get msgTaskLinkCopied => 'Task link copied.'; + + @override + String get msgWaitingForOpponentsDecision => + 'Waiting for your opponent\'s decision...'; + + @override + String get msgYouCannotPass => 'You cannot pass'; + + @override + String get msgYourOpponentDisagreesWithCountingResult => + 'Your opponent disagrees with the counting result'; + + @override + String get msgYourOpponentRefusesToCount => 'Your opponent refuses to count'; + + @override + String get msgYourOpponentRequestsAutomaticCounting => + 'Your opponent requests automatic counting. Do you agree?'; + + @override + String get myGames => 'My games'; + + @override + String get myMistakes => 'My mistakes'; + + @override + String nTasks(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString tasks', + one: '1 task', + zero: 'No tasks', + ); + return '$_temp0'; + } + + @override + String nTasksAvailable(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString tasks available', + one: '1 task available', + zero: 'No tasks available', + ); + return '$_temp0'; + } + + @override + String get newBestResult => 'New best!'; + + @override + String get no => 'No'; + + @override + String get none => 'None'; + + @override + String get numberOfTasks => 'Number of tasks'; + + @override + String nxnBoardSize(int n) { + return '$n×$n'; + } + + @override + String get ogsDesc => + 'The premier online Go platform with tournaments, AI analysis, and a vibrant community.'; + + @override + String get ogsName => 'Online Go Server'; + + @override + String get ok => 'OK'; + + @override + String get pass => 'Pass'; + + @override + String get password => '密码'; + + @override + String get play => '对局'; + + @override + String get promotionRequirements => 'Promotion requirements'; + + @override + String pxsByoyomi(int p, int s) { + return '$p×${s}s'; + } + + @override + String get rank => 'Rank'; + + @override + String get rankedMode => 'Ranked mode'; + + @override + String get recentRecord => 'Recent record'; + + @override + String get register => '注册'; + + @override + String get resign => 'Resign'; + + @override + String get responseDelay => '响应延迟'; + + @override + String get responseDelayDesc => '解题时响应前的延迟时长'; + + @override + String get responseDelayLong => '长'; + + @override + String get responseDelayMedium => '中'; + + @override + String get responseDelayNone => '无'; + + @override + String get responseDelayShort => '短'; + + @override + String get result => '结果'; + + @override + String get rules => '规则'; + + @override + String get rulesChinese => '中国规则'; + + @override + String get rulesJapanese => '日本规则'; + + @override + String get rulesKorean => '韩国规则'; + + @override + String get save => '保存'; + + @override + String get saveSGF => '保存SGF'; + + @override + String get seconds => '秒'; + + @override + String get settings => '设置'; + + @override + String get short => 'Short'; + + @override + String get showCoordinates => '显示坐标'; + + @override + String get simple => '普通'; + + @override + String get sortModeDifficult => 'Difficult'; + + @override + String get sortModeRecent => 'Recent'; + + @override + String get sound => '声音'; + + @override + String get start => '开始'; + + @override + String get statistics => 'Statistics'; + + @override + String get statsDateColumn => 'Date'; + + @override + String get statsDurationColumn => 'Time'; + + @override + String get statsTimeColumn => 'Time'; + + @override + String get stoneShadows => '棋子阴影'; + + @override + String get stones => 'Stones'; + + @override + String get subtopic => 'Subtopic'; + + @override + String get system => '系统'; + + @override + String get task => '任务'; + + @override + String get taskCorrect => 'Correct'; + + @override + String get taskNext => 'Next'; + + @override + String get taskRedo => 'Redo'; + + @override + String get taskSource => 'Task source'; + + @override + String get taskSourceFromMyMistakes => 'From my mistakes'; + + @override + String get taskSourceFromTaskTopic => 'From task topic'; + + @override + String get taskSourceFromTaskTypes => 'From task types'; + + @override + String get taskTag_afterJoseki => '定式之后'; + + @override + String get taskTag_aiOpening => 'AI布局'; + + @override + String get taskTag_aiVariations => 'AI变化'; + + @override + String get taskTag_attack => '攻击'; + + @override + String get taskTag_attackAndDefenseInKo => '劫的攻防'; + + @override + String get taskTag_attackAndDefenseOfCuts => '切断的处理'; + + @override + String get taskTag_attackAndDefenseOfInvadingStones => '征子的攻防'; + + @override + String get taskTag_avoidKo => '避劫'; + + @override + String get taskTag_avoidMakingDeadShape => '避免被聚杀'; + + @override + String get taskTag_avoidTrap => '避开陷阱'; + + @override + String get taskTag_basicEndgame => '基础官子'; + + @override + String get taskTag_basicLifeAndDeath => '基本死活'; + + @override + String get taskTag_basicMoves => '基本行棋'; + + @override + String get taskTag_basicTesuji => '基本手筋'; + + @override + String get taskTag_beginner => '启蒙'; + + @override + String get taskTag_bend => '弯'; + + @override + String get taskTag_bentFour => '弯四'; + + @override + String get taskTag_bentFourInTheCorner => '盘角曲四'; + + @override + String get taskTag_bentThree => '弯三'; + + @override + String get taskTag_bigEyeLiberties => '大眼的气'; + + @override + String get taskTag_bigEyeVsSmallEye => '大眼杀小眼'; + + @override + String get taskTag_bigPoints => '大场'; + + @override + String get taskTag_blindSpot => '盲点'; + + @override + String get taskTag_breakEye => '破眼'; + + @override + String get taskTag_breakEyeInOneStep => '一步破眼'; + + @override + String get taskTag_breakEyeInSente => '先手破眼'; + + @override + String get taskTag_breakOut => '突围'; + + @override + String get taskTag_breakPoints => '破目'; + + @override + String get taskTag_breakShape => '破坏棋形'; + + @override + String get taskTag_bridgeUnder => '渡'; + + @override + String get taskTag_brilliantSequence => '一一妙手'; + + @override + String get taskTag_bulkyFive => '刀把五'; + + @override + String get taskTag_bump => '顶'; + + @override + String get taskTag_captureBySnapback => '扑吃'; + + @override + String get taskTag_captureInLadder => '征吃'; + + @override + String get taskTag_captureInOneMove => '一步吃子'; + + @override + String get taskTag_captureOnTheSide => '边线吃子'; + + @override + String get taskTag_captureToLive => '吃子做活'; + + @override + String get taskTag_captureTwoRecaptureOne => '打二还一'; + + @override + String get taskTag_capturingRace => '对杀'; + + @override + String get taskTag_capturingTechniques => '吃子技巧'; + + @override + String get taskTag_carpentersSquareAndSimilar => '金柜角及类似型'; + + @override + String get taskTag_chooseTheFight => '战斗的选择'; + + @override + String get taskTag_clamp => '夹'; + + @override + String get taskTag_clampCapture => '夹吃'; + + @override + String get taskTag_closeInCapture => '门吃'; + + @override + String get taskTag_combination => '组合手段'; + + @override + String get taskTag_commonLifeAndDeath => '常型死活'; + + @override + String get taskTag_compareSize => '比较大小'; + + @override + String get taskTag_compareValue => '价值比较'; + + @override + String get taskTag_completeKoToSecureEndgameAdvantage => '粘劫收后'; + + @override + String get taskTag_compositeProblems => '复合问题'; + + @override + String get taskTag_comprehensiveTasks => '综合'; + + @override + String get taskTag_connect => '连络'; + + @override + String get taskTag_connectAndDie => '接不归'; + + @override + String get taskTag_connectInOneMove => '一步连接'; + + @override + String get taskTag_contactFightTesuji => '接触战的手筋'; + + @override + String get taskTag_contactPlay => '靠'; + + @override + String get taskTag_corner => '角部常型'; + + @override + String get taskTag_cornerIsGoldSideIsSilverCenterIsGrass => '金角银边草肚皮'; + + @override + String get taskTag_counter => '应对'; + + @override + String get taskTag_counterAttack => '反击'; + + @override + String get taskTag_cranesNest => '乌龟不出头'; + + @override + String get taskTag_crawl => '爬'; + + @override + String get taskTag_createShortageOfLiberties => '导致气紧'; + + @override + String get taskTag_crossedFive => '梅花五'; + + @override + String get taskTag_cut => '断'; + + @override + String get taskTag_cut2 => '分断'; + + @override + String get taskTag_cutAcross => '跨'; + + @override + String get taskTag_defendFromInvasion => '防守入侵'; + + @override + String get taskTag_defendPoints => '守目'; + + @override + String get taskTag_defendWeakPoint => '防范弱点'; + + @override + String get taskTag_descent => '立'; + + @override + String get taskTag_diagonal => '尖'; + + @override + String get taskTag_directionOfCapture => '吃子方向'; + + @override + String get taskTag_directionOfEscape => '逃子方向'; + + @override + String get taskTag_directionOfPlay => '方向选择'; + + @override + String get taskTag_doNotUnderestimateOpponent => '不要忽略对方的抵抗'; + + @override + String get taskTag_doubleAtari => '双吃'; + + @override + String get taskTag_doubleCapture => '双提'; + + @override + String get taskTag_doubleKo => '连环劫'; + + @override + String get taskTag_doubleSenteEndgame => '双先官子'; + + @override + String get taskTag_doubleSnapback => '双倒扑'; + + @override + String get taskTag_endgame => '官子'; + + @override + String get taskTag_endgameFundamentals => '基本收官'; + + @override + String get taskTag_endgameIn5x5 => '5路官子'; + + @override + String get taskTag_endgameOn4x4 => '4路官子'; + + @override + String get taskTag_endgameTesuji => '官子手筋'; + + @override + String get taskTag_engulfingAtari => '抱吃'; + + @override + String get taskTag_escape => '逃子'; + + @override + String get taskTag_escapeInOneMove => '一步逃子'; + + @override + String get taskTag_exploitShapeWeakness => '利用棋形弱点'; + + @override + String get taskTag_eyeVsNoEye => '有眼杀无眼'; + + @override + String get taskTag_fillNeutralPoints => '目与单官'; + + @override + String get taskTag_findTheRoot => '搜根'; + + @override + String get taskTag_firstLineBrilliantMove => '一路妙手'; + + @override + String get taskTag_flowerSix => '葡萄六'; + + @override + String get taskTag_goldenChickenStandingOnOneLeg => '金鸡独立'; + + @override + String get taskTag_groupLiberties => '棋子的气'; + + @override + String get taskTag_groupsBase => '棋子的根据地'; + + @override + String get taskTag_hane => '扳'; + + @override + String get taskTag_increaseEyeSpace => '扩大眼位'; + + @override + String get taskTag_increaseLiberties => '延气'; + + @override + String get taskTag_indirectAttack => '间接进攻'; + + @override + String get taskTag_influenceKeyPoints => '势力消长的要点'; + + @override + String get taskTag_insideKill => '聚杀'; + + @override + String get taskTag_insideMoves => '内部动手'; + + @override + String get taskTag_interestingTasks => '趣题'; + + @override + String get taskTag_internalLibertyShortage => '胀牯牛'; + + @override + String get taskTag_invadingTechnique => '入侵的手段'; + + @override + String get taskTag_invasion => '打入'; + + @override + String get taskTag_jGroupAndSimilar => '大猪嘴及类似型'; + + @override + String get taskTag_josekiFundamentals => '基本定式'; + + @override + String get taskTag_jump => '跳'; + + @override + String get taskTag_keepSente => '保留先手'; + + @override + String get taskTag_killAfterCapture => '提子后的杀着'; + + @override + String get taskTag_killByEyePointPlacement => '点杀'; + + @override + String get taskTag_knightsMove => '飞'; + + @override + String get taskTag_ko => '打劫'; + + @override + String get taskTag_kosumiWedge => '挤'; + + @override + String get taskTag_largeKnightsMove => '大飞'; + + @override + String get taskTag_largeMoyoFight => '大模样作战'; + + @override + String get taskTag_lifeAndDeath => '死活'; + + @override + String get taskTag_lifeAndDeathOn4x4 => '4路死活'; + + @override + String get taskTag_lookForLeverage => '寻求借用'; + + @override + String get taskTag_looseLadder => '宽征'; + + @override + String get taskTag_lovesickCut => '相思断'; + + @override + String get taskTag_makeEye => '做眼'; + + @override + String get taskTag_makeEyeInOneStep => '一步做眼'; + + @override + String get taskTag_makeEyeInSente => '先手做眼'; + + @override + String get taskTag_makeKo => '做劫'; + + @override + String get taskTag_makeShape => '定形技巧'; + + @override + String get taskTag_middlegame => '中盘'; + + @override + String get taskTag_monkeyClimbingMountain => '猴子翻山'; + + @override + String get taskTag_mouseStealingOil => '老鼠偷油'; + + @override + String get taskTag_moveOut => '出头'; + + @override + String get taskTag_moveTowardsEmptySpace => '棋往宽处走'; + + @override + String get taskTag_multipleBrilliantMoves => '一二妙手'; + + @override + String get taskTag_net => '枷'; + + @override + String get taskTag_netCapture => '枷吃'; + + @override + String get taskTag_observeSubtleDifference => '注意细微差别'; + + @override + String get taskTag_occupyEncloseAndApproachCorner => '占角、守角和挂角'; + + @override + String get taskTag_oneStoneTwoPurposes => '一子两用'; + + @override + String get taskTag_opening => '布局'; + + @override + String get taskTag_openingChoice => '定式选择'; + + @override + String get taskTag_openingFundamentals => '布局基本下法'; + + @override + String get taskTag_orderOfEndgameMoves => '收束次序'; + + @override + String get taskTag_orderOfMoves => '行棋次序'; + + @override + String get taskTag_orderOfMovesInKo => '区分劫的先后手'; + + @override + String get taskTag_orioleCapturesButterfly => '黄莺扑蝶'; + + @override + String get taskTag_pincer => '夹击'; + + @override + String get taskTag_placement => '点'; + + @override + String get taskTag_plunderingTechnique => '搜刮的手段'; + + @override + String get taskTag_preventBambooJoint => '靠单'; + + @override + String get taskTag_preventBridgingUnder => '阻渡'; + + @override + String get taskTag_preventOpponentFromApproaching => '使对方不入'; + + @override + String get taskTag_probe => '试探应手'; + + @override + String get taskTag_profitInSente => '先手获利'; + + @override + String get taskTag_profitUsingLifeAndDeath => '利用死活问题获利'; + + @override + String get taskTag_push => '冲'; + + @override + String get taskTag_pyramidFour => '丁四'; + + @override + String get taskTag_realEyeAndFalseEye => '真眼和假眼'; + + @override + String get taskTag_rectangularSix => '板六'; + + @override + String get taskTag_reduceEyeSpace => '缩小眼位'; + + @override + String get taskTag_reduceLiberties => '紧气'; + + @override + String get taskTag_reduction => '侵消'; + + @override + String get taskTag_runWeakGroup => '出动残子'; + + @override + String get taskTag_sabakiAndUtilizingInfluence => '腾挪与借用'; + + @override + String get taskTag_sacrifice => '弃子'; + + @override + String get taskTag_sacrificeAndSqueeze => '滚打包收'; + + @override + String get taskTag_sealIn => '封锁'; + + @override + String get taskTag_secondLine => '二线型'; + + @override + String get taskTag_seizeTheOpportunity => '把握战机'; + + @override + String get taskTag_seki => '双活'; + + @override + String get taskTag_senteAndGote => '先手与后手'; + + @override + String get taskTag_settleShape => '整形'; + + @override + String get taskTag_settleShapeInSente => '先手定形'; + + @override + String get taskTag_shape => '棋形'; + + @override + String get taskTag_shapesVitalPoint => '棋形要点'; + + @override + String get taskTag_side => '边部常型'; + + @override + String get taskTag_smallBoardEndgame => '小棋盘官子'; + + @override + String get taskTag_snapback => '倒扑'; + + @override + String get taskTag_solidConnection => '接'; + + @override + String get taskTag_solidExtension => '长'; + + @override + String get taskTag_splitInOneMove => '一步分断'; + + @override + String get taskTag_splittingMove => '分投'; + + @override + String get taskTag_squareFour => '方四'; + + @override + String get taskTag_squeeze => '滚打'; + + @override + String get taskTag_standardCapturingRaces => '常型对杀'; + + @override + String get taskTag_standardCornerAndSideEndgame => '边角常型收束'; + + @override + String get taskTag_straightFour => '直四'; + + @override + String get taskTag_straightThree => '直三'; + + @override + String get taskTag_surroundTerritory => '围空'; + + @override + String get taskTag_symmetricShape => '左右同型'; + + @override + String get taskTag_techniqueForReinforcingGroups => '补棋的方法'; + + @override + String get taskTag_techniqueForSecuringTerritory => '地中的手段'; + + @override + String get taskTag_textbookTasks => '文字题'; + + @override + String get taskTag_thirdAndFourthLine => '三路和四路'; + + @override + String get taskTag_threeEyesTwoActions => '三眼两做'; + + @override + String get taskTag_threeSpaceExtensionFromTwoStones => '立二拆三'; + + @override + String get taskTag_throwIn => '扑'; + + @override + String get taskTag_tigersMouth => '虎'; + + @override + String get taskTag_tombstoneSqueeze => '大头鬼'; + + @override + String get taskTag_tripodGroupWithExtraLegAndSimilar => '小猪嘴及类似型'; + + @override + String get taskTag_twoHaneGainOneLiberty => '两扳长一气'; + + @override + String get taskTag_twoHeadedDragon => '盘龙眼'; + + @override + String get taskTag_twoSpaceExtension => '拆二'; + + @override + String get taskTag_typesOfKo => '区分劫的种类'; + + @override + String get taskTag_underTheStones => '倒脱靴'; + + @override + String get taskTag_underneathAttachment => '托'; + + @override + String get taskTag_urgentPointOfAFight => '战斗的急所'; + + @override + String get taskTag_urgentPoints => '急所'; + + @override + String get taskTag_useConnectAndDie => '利用接不归'; + + @override + String get taskTag_useCornerSpecialProperties => '利用角部特殊性'; + + @override + String get taskTag_useDescentToFirstLine => '利用一路硬腿'; + + @override + String get taskTag_useInfluence => '厚势的作用'; + + @override + String get taskTag_useOpponentsLifeAndDeath => '利用对方死活'; + + @override + String get taskTag_useShortageOfLiberties => '利用气紧'; + + @override + String get taskTag_useSnapback => '利用倒扑'; + + @override + String get taskTag_useSurroundingStones => '利用外围棋子'; + + @override + String get taskTag_vitalAndUselessStones => '要子与废子'; + + @override + String get taskTag_vitalPointForBothSides => '双方要点'; + + @override + String get taskTag_vitalPointForCapturingRace => '对杀要点'; + + @override + String get taskTag_vitalPointForIncreasingLiberties => '延气要点'; + + @override + String get taskTag_vitalPointForKill => '杀棋要点'; + + @override + String get taskTag_vitalPointForLife => '活棋要点'; + + @override + String get taskTag_vitalPointForReducingLiberties => '紧气要点'; + + @override + String get taskTag_wedge => '挖'; + + @override + String get taskTag_wedgingCapture => '挖吃'; + + @override + String get taskTimeout => 'Timeout'; + + @override + String get taskTypeAppreciation => 'Appreciation'; + + @override + String get taskTypeCapture => 'Capture stones'; + + @override + String get taskTypeCaptureRace => 'Capture race'; + + @override + String get taskTypeEndgame => 'Endgame'; + + @override + String get taskTypeJoseki => 'Joseki'; + + @override + String get taskTypeLifeAndDeath => 'Life & death'; + + @override + String get taskTypeMiddlegame => 'Middlegame'; + + @override + String get taskTypeOpening => 'Opening'; + + @override + String get taskTypeTesuji => 'Tesuji'; + + @override + String get taskTypeTheory => 'Theory'; + + @override + String get taskWrong => 'Wrong'; + + @override + String get tasksSolved => 'Tasks solved'; + + @override + String get test => 'Test'; + + @override + String get theme => '主题'; + + @override + String get thick => '粗线'; + + @override + String get timeFrenzy => 'Time frenzy'; + + @override + String get timePerTask => 'Time per task'; + + @override + String get today => 'Today'; + + @override + String get tooltipAnalyzeWithAISensei => 'Analyze with AI Sensei'; + + @override + String get tooltipDownloadGame => 'Download game'; + + @override + String get topic => 'Topic'; + + @override + String get topicExam => 'Topic exam'; + + @override + String get topics => 'Topics'; + + @override + String get train => '练习'; + + @override + String get trainingAvgTimePerTask => 'Avg time per task'; + + @override + String get trainingFailed => 'Failed'; + + @override + String get trainingMistakes => 'Mistakes'; + + @override + String get trainingPassed => 'Passed'; + + @override + String get trainingTotalTime => 'Total time'; + + @override + String get tryCustomMoves => 'Try custom moves'; + + @override + String get tygemDesc => + 'The most popular server in Korea and one of the most popular in the world.'; + + @override + String get tygemName => 'Tygem Baduk'; + + @override + String get type => 'Type'; + + @override + String get ui => 'UI'; + + @override + String get userInfo => '用户信息'; + + @override + String get username => '用户名'; + + @override + String get voice => 'Voice'; + + @override + String get week => 'Week'; + + @override + String get white => 'White'; + + @override + String get yes => 'Yes'; +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 00000000..eeeb91b7 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,489 @@ +{ + "about": "О приложении", + "accuracy": "Точность", + "aiReferee": "ИИ-судья", + "aiSensei": "AI Sensei", + "alwaysBlackToPlay": "Всегда ход чёрных", + "alwaysBlackToPlayDesc": "Устанавливать все задачи как ход чёрных, чтобы избежать путаницы", + "appearance": "Appearance", + "autoCounting": "Автоподсчёт", + "autoMatch": "Auto-Match", + "behaviour": "Behaviour", + "bestResult": "Лучший результат", + "black": "Чёрные", + "board": "Доска", + "boardSize": "Размер доски", + "boardTheme": "Тема доски", + "byRank": "По рангу", + "cancel": "Отмена", + "captures": "Захваты", + "collectStats": "Collect statistics", + "collections": "Коллекции", + "confirm": "Confirm", + "confirmBoardSize": "Confirm board size", + "confirmBoardSizeDesc": "Boards this size or larger require move confirmation", + "confirmMoves": "Confirm moves", + "confirmMovesDesc": "Double-tap to confirm moves on large boards to avoid misclicks", + "continue_": "Продолжить", + "copyTaskLink": "Копировать ссылку на задачу", + "customExam": "Custom exam", + "dark": "Тёмная", + "dontShowAgain": "Больше не показывать", + "download": "Скачать", + "edgeLine": "Линия края", + "endgameExam": "Endgame exam", + "enterTaskLink": "Введите ссылку на задачу", + "errCannotBeEmpty": "Cannot be empty", + "errFailedToDownloadGame": "Не удалось скачать партию", + "errFailedToLoadGameList": "Не удалось загрузить список партий. Пожалуйста, попробуйте снова.", + "errFailedToUploadGameToAISensei": "Не удалось загрузить партию в AI Sensei", + "errIncorrectUsernameOrPassword": "Неверное имя пользователя или пароль", + "errMustBeAtLeast": "Must be at least {n}", + "@errMustBeAtLeast": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeAtMost": "Must be at most {n}", + "@errMustBeAtMost": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeInteger": "Must be an integer", + "exit": "Exit", + "exitTryMode": "Exit try mode", + "find": "Найти", + "findTask": "Найти задачу", + "forceCounting": "Force counting", + "foxwqDesc": "Самый популярный сервер в Китае и в мире.", + "foxwqName": "Фокс Вэйци", + "gameInfo": "Информация о партии", + "gameRecord": "Запись партии", + "gradingExam": "Grading exam", + "handicap": "Фора", + "help": "Помощь", + "helpDialogCollections": "Collections are classic, curated sets of high-quality tasks which hold special value together as a training resource.\n\nThe main goal is to solve a collection with a high success rate. A secondary goal is to solve it as fast as possible.\n\nTo start or continue solving a collection, slide left on the collection tile while in portrait mode or click the Start/Continue buttons while in landscape mode.", + "helpDialogEndgameExam": "- Endgame exams are sets of 10 endgame tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.", + "helpDialogGradingExam": "- Grading exams are sets of 10 tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.", + "helpDialogRankedMode": "- Solve tasks without a time limit.\n\n- Task difficulty increases according to how fast you solve them.\n\n- Focus on solving correctly and reach the highest rank possible.", + "helpDialogTimeFrenzy": "- You have 3 minutes to solve as many tasks as possible.\n\n- Tasks get increasingly difficult as you solve them.\n\n- If you make 3 mistakes, you are out.", + "home": "Главная", + "komi": "Коми", + "language": "Язык", + "leave": "Leave", + "light": "Светлая", + "login": "Войти", + "logout": "Выйти", + "long": "Long", + "mMinutes": "{m} мин", + "@mMinutes": { + "placeholders": { + "m": { + "type": "int" + } + } + }, + "maxNumberOfMistakes": "Максимальное количество ошибок", + "maxRank": "Макс. ранг", + "medium": "Medium", + "minRank": "Мин. ранг", + "minutes": "Минуты", + "month": "Month", + "msgCannotUseAIRefereeYet": "ИИ-судья ещё нельзя использовать", + "msgCannotUseForcedCountingYet": "Forced counting cannot be used yet", + "msgConfirmDeleteCollectionProgress": "Are you sure that you want to delete the previous attempt?", + "msgConfirmResignation": "Вы уверены, что хотите сдаться?", + "msgConfirmStopEvent": "Вы уверены, что хотите остановить {event}?", + "@msgConfirmStopEvent": { + "placeholders": { + "event": { + "type": "String" + } + } + }, + "msgDownloadingGame": "Скачивание партии", + "msgGameSavedTo": "Партия сохранена в {path}", + "@msgGameSavedTo": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "msgPleaseWaitForYourTurn": "Пожалуйста, дождитесь своего хода", + "msgSearchingForGame": "Поиск партии...", + "msgTaskLinkCopied": "Ссылка на задачу скопирована.", + "msgWaitingForOpponentsDecision": "Ожидание решения вашего оппонента...", + "msgYouCannotPass": "Вы не можете пасовать", + "msgYourOpponentDisagreesWithCountingResult": "Ваш оппонент не согласен с результатом подсчёта", + "msgYourOpponentRefusesToCount": "Ваш оппонент отказывается от подсчёта", + "msgYourOpponentRequestsAutomaticCounting": "Ваш оппонент запрашивает автоматический подсчёт. Вы согласны?", + "myGames": "Мои партии", + "myMistakes": "Мои ошибки", + "nTasks": "{count, plural, =0{Нет задач} =1{1 задача} other{{count} задач}}", + "@nTasks": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "nTasksAvailable": "{count, plural, =0{Нет доступных задач} =1{1 задача доступна} other{{count} задач доступно}}", + "@nTasksAvailable": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "newBestResult": "Новый рекорд!", + "no": "Нет", + "none": "Нет", + "numberOfTasks": "Количество задач", + "nxnBoardSize": "{n}×{n}", + "@nxnBoardSize": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "ogsDesc": "Главная онлайн-платформа для игры в Го с турнирами, анализом от ИИ и активным сообществом.", + "ogsName": "Online Go Server", + "ok": "ОК", + "pass": "Пас", + "password": "Пароль", + "play": "Играть", + "promotionRequirements": "Promotion requirements", + "pxsByoyomi": "{p}×{s}с", + "@pxsByoyomi": { + "placeholders": { + "p": { + "type": "int" + }, + "s": { + "type": "int" + } + } + }, + "rank": "Ранг", + "rankedMode": "Ranked mode", + "recentRecord": "Recent record", + "register": "Зарегистрироваться", + "resign": "Сдаться", + "responseDelay": "Response delay", + "responseDelayDesc": "Duration of the delay before the response appears while solving tasks", + "responseDelayLong": "Длинная", + "responseDelayMedium": "Средняя", + "responseDelayNone": "Нет", + "responseDelayShort": "Короткая", + "result": "Результат", + "rules": "Правила", + "rulesChinese": "Китайские", + "rulesJapanese": "Японские", + "rulesKorean": "Корейские", + "save": "Сохранить", + "saveSGF": "Сохранить SGF", + "seconds": "Секунды", + "settings": "Настройки", + "short": "Short", + "showCoordinates": "Показывать координаты", + "simple": "Simple", + "sortModeDifficult": "Сложные", + "sortModeRecent": "Недавние", + "sound": "Звук", + "start": "Начать", + "statistics": "Статистика", + "statsDateColumn": "Дата", + "statsDurationColumn": "Время", + "statsTimeColumn": "Время", + "stoneShadows": "Тени камней", + "stones": "Камни", + "subtopic": "Подтема", + "system": "Система", + "task": "Задача", + "taskCorrect": "Верно", + "taskNext": "Следующая", + "taskRedo": "Переделать", + "taskSource": "Task source", + "taskSourceFromMyMistakes": "Из моих ошибок", + "taskSourceFromTaskTopic": "Из темы задач", + "taskSourceFromTaskTypes": "Из типов задач", + "taskTag_afterJoseki": "После джосеки", + "taskTag_aiOpening": "AI opening", + "taskTag_aiVariations": "AI variations", + "taskTag_attack": "Атака", + "taskTag_attackAndDefenseInKo": "Attack and defense in a ko", + "taskTag_attackAndDefenseOfCuts": "Атака и защита разрезаний", + "taskTag_attackAndDefenseOfInvadingStones": "Attack and defense of invading stones", + "taskTag_avoidKo": "Избежать ко", + "taskTag_avoidMakingDeadShape": "Avoid making dead shape", + "taskTag_avoidTrap": "Avoid trap", + "taskTag_basicEndgame": "Endgame: basic", + "taskTag_basicLifeAndDeath": "Базовые задачи на жизнь и смерть", + "taskTag_basicMoves": "Базовые ходы", + "taskTag_basicTesuji": "Tesuji", + "taskTag_beginner": "Базовые приемы", + "taskTag_bend": "Bend", + "taskTag_bentFour": "Изогнутая четверка", + "taskTag_bentFourInTheCorner": "Изогнутая четверка в углу", + "taskTag_bentThree": "Изогнутая тройка", + "taskTag_bigEyeLiberties": "Big eye's liberties", + "taskTag_bigEyeVsSmallEye": "Группа с большим глазом против группы с маленьким глазом", + "taskTag_bigPoints": "Большой пункт", + "taskTag_blindSpot": "Слепое пятно", + "taskTag_breakEye": "Разрушение глаза", + "taskTag_breakEyeInOneStep": "Разрушение глаза в один ход", + "taskTag_breakEyeInSente": "Разрушение глаза в сенте", + "taskTag_breakOut": "Break out", + "taskTag_breakPoints": "Break points", + "taskTag_breakShape": "Разрушить форму", + "taskTag_bridgeUnder": "Bridge under", + "taskTag_brilliantSequence": "Brilliant sequence", + "taskTag_bulkyFive": "Машинка", + "taskTag_bump": "Удар", + "taskTag_captureBySnapback": "Захват в защелку", + "taskTag_captureInLadder": "Захват лестницы", + "taskTag_captureInOneMove": "Захват в один ход", + "taskTag_captureOnTheSide": "Захват на стороне", + "taskTag_captureToLive": "Capture to live", + "taskTag_captureTwoRecaptureOne": "Захвати два - верни один", + "taskTag_capturingRace": "Семеай", + "taskTag_capturingTechniques": "Техника захвата", + "taskTag_carpentersSquareAndSimilar": "Плотницкий квадрат и похожее", + "taskTag_chooseTheFight": "Выбрать бой", + "taskTag_clamp": "Зажим", + "taskTag_clampCapture": "Clamp capture", + "taskTag_closeInCapture": "Closing-in capture", + "taskTag_combination": "Комбинация", + "taskTag_commonLifeAndDeath": "Life \u0026 death: common shapes", + "taskTag_compareSize": "Compare size", + "taskTag_compareValue": "Compare value", + "taskTag_completeKoToSecureEndgameAdvantage": "Complete ko to secure endgame advantage", + "taskTag_compositeProblems": "Composite tasks", + "taskTag_comprehensiveTasks": "Comprehensive tasks", + "taskTag_connect": "Соединение", + "taskTag_connectAndDie": "Connect and die", + "taskTag_connectInOneMove": "Соединение в один ход", + "taskTag_contactFightTesuji": "Техника ближнего боя", + "taskTag_contactPlay": "Contact play", + "taskTag_corner": "Угол", + "taskTag_cornerIsGoldSideIsSilverCenterIsGrass": "Угол - золото, сторона - серебро, центр - трава.", + "taskTag_counter": "Counter", + "taskTag_counterAttack": "Контратака", + "taskTag_cranesNest": "Crane's nest", + "taskTag_crawl": "Crawl", + "taskTag_createShortageOfLiberties": "Создание нехватки дыханий", + "taskTag_crossedFive": "Crossed five", + "taskTag_cut": "Разрезание", + "taskTag_cut2": "Разрезание", + "taskTag_cutAcross": "Cut across", + "taskTag_defendFromInvasion": "Защита от вторжения", + "taskTag_defendPoints": "Defend points", + "taskTag_defendWeakPoint": "Defend weak point", + "taskTag_descent": "Descent", + "taskTag_diagonal": "Косуми", + "taskTag_directionOfCapture": "Направление захвата", + "taskTag_directionOfEscape": "Направление побега", + "taskTag_directionOfPlay": "Направление игры", + "taskTag_doNotUnderestimateOpponent": "Не стоит недооценивать противника", + "taskTag_doubleAtari": "Двойное атари", + "taskTag_doubleCapture": "Double capture", + "taskTag_doubleKo": "Двойное ко", + "taskTag_doubleSenteEndgame": "Двойное сенте в йосе", + "taskTag_doubleSnapback": "Двойная защелка", + "taskTag_endgame": "Endgame: general", + "taskTag_endgameFundamentals": "Endgame fundamentals", + "taskTag_endgameIn5x5": "5х5 йосе", + "taskTag_endgameOn4x4": "4х4 йосе", + "taskTag_endgameTesuji": "Йосе-тесуджи", + "taskTag_engulfingAtari": "Engulfing atari", + "taskTag_escape": "Побег", + "taskTag_escapeInOneMove": "Побег в один ход", + "taskTag_exploitShapeWeakness": "Exploit shape weakness", + "taskTag_eyeVsNoEye": "Группа с глазом против группы без глаза", + "taskTag_fillNeutralPoints": "Заполнение нейтральных пунктов", + "taskTag_findTheRoot": "Find the root", + "taskTag_firstLineBrilliantMove": "Блестящий ход на 1-й линии", + "taskTag_flowerSix": "Flower six", + "taskTag_goldenChickenStandingOnOneLeg": "Golden rooster standing on one leg", + "taskTag_groupLiberties": "Group liberties", + "taskTag_groupsBase": "Group's base", + "taskTag_hane": "Хане", + "taskTag_increaseEyeSpace": "Увеличить глазное пространство", + "taskTag_increaseLiberties": "Наращивание дыханий", + "taskTag_indirectAttack": "Косвенная атака", + "taskTag_influenceKeyPoints": "Influence key points", + "taskTag_insideKill": "Inside kill", + "taskTag_insideMoves": "Inside moves", + "taskTag_interestingTasks": "Интересные задания", + "taskTag_internalLibertyShortage": "Internal liberty shortage", + "taskTag_invadingTechnique": "Invading technique", + "taskTag_invasion": "Вторжение", + "taskTag_jGroupAndSimilar": "J-group and similar", + "taskTag_josekiFundamentals": "Joseki fundamentals", + "taskTag_jump": "Прыжок", + "taskTag_keepSente": "Сохранить сенте", + "taskTag_killAfterCapture": "Убийство после захвата", + "taskTag_killByEyePointPlacement": "Kill by eye point placement", + "taskTag_knightsMove": "Knight's move", + "taskTag_ko": "Ко", + "taskTag_kosumiWedge": "Kosumi wedge", + "taskTag_largeKnightsMove": "Large knight move", + "taskTag_largeMoyoFight": "Large moyo fight", + "taskTag_lifeAndDeath": "Жизнь и смерть", + "taskTag_lifeAndDeathOn4x4": "4х4 жизнь и смерть", + "taskTag_lookForLeverage": "Look for leverage", + "taskTag_looseLadder": "Loose ladder", + "taskTag_lovesickCut": "Lovesick cut", + "taskTag_makeEye": "Построение глаза", + "taskTag_makeEyeInOneStep": "Построение глаза в один ход", + "taskTag_makeEyeInSente": "Построение глаза в сенте", + "taskTag_makeKo": "Начать ко", + "taskTag_makeShape": "Построить форму", + "taskTag_middlegame": "Середина игры", + "taskTag_monkeyClimbingMountain": "Monkey climbing the mountain", + "taskTag_mouseStealingOil": "Mouse stealing oil", + "taskTag_moveOut": "Убегание", + "taskTag_moveTowardsEmptySpace": "Двигаться к пустому пространству", + "taskTag_multipleBrilliantMoves": "Multiple brilliant moves", + "taskTag_net": "Net", + "taskTag_netCapture": "Net capture", + "taskTag_observeSubtleDifference": "Заметить тонкую разницу", + "taskTag_occupyEncloseAndApproachCorner": "Occupy, enclose and approach corners", + "taskTag_oneStoneTwoPurposes": "Один ход, две цели", + "taskTag_opening": "Фусеки", + "taskTag_openingChoice": "Opening choice", + "taskTag_openingFundamentals": "Opening fundamentals", + "taskTag_orderOfEndgameMoves": "Порядок ходов в йосе", + "taskTag_orderOfMoves": "Порядок ходов", + "taskTag_orderOfMovesInKo": "Порядок ходов в ко", + "taskTag_orioleCapturesButterfly": "Oriole captures the butterfly", + "taskTag_pincer": "Pincer", + "taskTag_placement": "Placement", + "taskTag_plunderingTechnique": "Plundering technique", + "taskTag_preventBambooJoint": "Prevent the bamboo joint", + "taskTag_preventBridgingUnder": "Prevent bridging under", + "taskTag_preventOpponentFromApproaching": "Prevent opponent from approaching", + "taskTag_probe": "Probe", + "taskTag_profitInSente": "Прибыль в сенте", + "taskTag_profitUsingLifeAndDeath": "Profit using life and death", + "taskTag_push": "Push", + "taskTag_pyramidFour": "Пирамида", + "taskTag_realEyeAndFalseEye": "Глаз против ложного глаза", + "taskTag_rectangularSix": "Rectangular six", + "taskTag_reduceEyeSpace": "Уменьшить глазное пространство", + "taskTag_reduceLiberties": "Сокращение дыханий", + "taskTag_reduction": "Reduction", + "taskTag_runWeakGroup": "Побег слабой группы", + "taskTag_sabakiAndUtilizingInfluence": "Sabaki and utilizing influence", + "taskTag_sacrifice": "Жертва", + "taskTag_sacrificeAndSqueeze": "Sacrifice and squeeze", + "taskTag_sealIn": "Seal in", + "taskTag_secondLine": "2-я линия", + "taskTag_seizeTheOpportunity": "Seize the opportunity", + "taskTag_seki": "Секи", + "taskTag_senteAndGote": "Сенте и готе", + "taskTag_settleShape": "Settle shape", + "taskTag_settleShapeInSente": "Settle shape in sente", + "taskTag_shape": "Форма", + "taskTag_shapesVitalPoint": "Жизненно важный пункт формы", + "taskTag_side": "Сторона", + "taskTag_smallBoardEndgame": "Йосе на маленькой доске", + "taskTag_snapback": "Защелка", + "taskTag_solidConnection": "Solid connection", + "taskTag_solidExtension": "Solid extension", + "taskTag_splitInOneMove": "Разделение в один ход", + "taskTag_splittingMove": "Splitting move", + "taskTag_squareFour": "Square four", + "taskTag_squeeze": "Сжатие", + "taskTag_standardCapturingRaces": "Standard capturing races", + "taskTag_standardCornerAndSideEndgame": "Стандартное йосе в углу и на стороне", + "taskTag_straightFour": "Четыре в ряд", + "taskTag_straightThree": "Три в ряд", + "taskTag_surroundTerritory": "Surround territory", + "taskTag_symmetricShape": "Симметричная форма", + "taskTag_techniqueForReinforcingGroups": "Техника усиления", + "taskTag_techniqueForSecuringTerritory": "Техника защиты территории", + "taskTag_textbookTasks": "Задачи из книг", + "taskTag_thirdAndFourthLine": "Third and fourth line", + "taskTag_threeEyesTwoActions": "Three eyes, two actions", + "taskTag_threeSpaceExtensionFromTwoStones": "Three-space extension from two stones", + "taskTag_throwIn": "Throw-in", + "taskTag_tigersMouth": "Пасть тигра", + "taskTag_tombstoneSqueeze": "Сжатие надгробной плиты", + "taskTag_tripodGroupWithExtraLegAndSimilar": "Tripod group with extra leg and similar", + "taskTag_twoHaneGainOneLiberty": "Double hane grows one liberty", + "taskTag_twoHeadedDragon": "Двухглавый дракон", + "taskTag_twoSpaceExtension": "Two-space extension", + "taskTag_typesOfKo": "Types of ko", + "taskTag_underTheStones": "Under the stones", + "taskTag_underneathAttachment": "Underneath attachment", + "taskTag_urgentPointOfAFight": "Urgent point of a fight", + "taskTag_urgentPoints": "Срочный пункт", + "taskTag_useConnectAndDie": "Use connect and die", + "taskTag_useCornerSpecialProperties": "Использование особых свойств угла", + "taskTag_useDescentToFirstLine": "Use descent to first line", + "taskTag_useInfluence": "Использовать влияние", + "taskTag_useOpponentsLifeAndDeath": "Use opponent's life and death", + "taskTag_useShortageOfLiberties": "Использовать нехватку дыханий", + "taskTag_useSnapback": "Использование защелки", + "taskTag_useSurroundingStones": "Использование внешних камней", + "taskTag_vitalAndUselessStones": "Важные и бесполезные камни", + "taskTag_vitalPointForBothSides": "Важный пункт для обеих сторон", + "taskTag_vitalPointForCapturingRace": "Vital point for capturing race", + "taskTag_vitalPointForIncreasingLiberties": "Vital point for increasing liberties", + "taskTag_vitalPointForKill": "Жизненно важный пункт для убийства", + "taskTag_vitalPointForLife": "Жизненно важный пункт для жизни", + "taskTag_vitalPointForReducingLiberties": "Важная точка для сокращения дыханий", + "taskTag_wedge": "Клин", + "taskTag_wedgingCapture": "Wedging capture", + "taskTimeout": "Время вышло", + "taskTypeAppreciation": "Appreciation", + "taskTypeCapture": "Capture stones", + "taskTypeCaptureRace": "Семеай", + "taskTypeEndgame": "Йосе", + "taskTypeJoseki": "Джосеки", + "taskTypeLifeAndDeath": "Жизнь и смерть", + "taskTypeMiddlegame": "Середина игры", + "taskTypeOpening": "Фусеки", + "taskTypeTesuji": "Тесуджи", + "taskTypeTheory": "Теория", + "taskWrong": "Неверно", + "tasksSolved": "Задач решено", + "test": "Тест", + "theme": "Тема", + "thick": "Thick", + "timeFrenzy": "Time frenzy", + "timePerTask": "Time per task", + "today": "Сегодня", + "tooltipAnalyzeWithAISensei": "Анализировать с AI Sensei", + "tooltipDownloadGame": "Скачать партию", + "topic": "Тема", + "topicExam": "Topic exam", + "topics": "Темы", + "train": "Тренироваться", + "trainingAvgTimePerTask": "Avg time per task", + "trainingFailed": "Failed", + "trainingMistakes": "Ошибки", + "trainingPassed": "Passed", + "trainingTotalTime": "Общее время", + "tryCustomMoves": "Попробовать свои ходы", + "tygemDesc": "Самый популярный сервер в Корее и один из самых популярных в мире.", + "tygemName": "Tygem Baduk", + "type": "Тип", + "ui": "Интерфейс", + "userInfo": "Информация о пользователе", + "username": "Имя пользователя", + "voice": "Голос", + "week": "Неделя", + "white": "Белые", + "yes": "Да" +} \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 00000000..de87239c --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,489 @@ +{ + "about": "关于", + "accuracy": "Accuracy", + "aiReferee": "AI referee", + "aiSensei": "AI Sensei", + "alwaysBlackToPlay": "总是执黑先行", + "alwaysBlackToPlayDesc": "永远执黑先行,防止混淆", + "appearance": "外观", + "autoCounting": "Auto counting", + "autoMatch": "Auto-Match", + "behaviour": "操作", + "bestResult": "最优结果", + "black": "Black", + "board": "棋盘", + "boardSize": "棋盘颜色", + "boardTheme": "棋盘主题", + "byRank": "By rank", + "cancel": "取消", + "captures": "Captures", + "collectStats": "Collect statistics", + "collections": "Collections", + "confirm": "确认", + "confirmBoardSize": "确认棋盘大小", + "confirmBoardSizeDesc": "Boards this size or larger require move confirmation", + "confirmMoves": "确定落子", + "confirmMovesDesc": "在较大的棋盘上双击以确认落子,避免误操作", + "continue_": "继续", + "copyTaskLink": "Copy task link", + "customExam": "Custom exam", + "dark": "Dark", + "dontShowAgain": "不再显示", + "download": "下载", + "edgeLine": "棋盘边缘", + "endgameExam": "Endgame exam", + "enterTaskLink": "Enter the task link", + "errCannotBeEmpty": "Cannot be empty", + "errFailedToDownloadGame": "Failed to download game", + "errFailedToLoadGameList": "Failed to load game list. Please try again.", + "errFailedToUploadGameToAISensei": "Failed to upload game to AI Sensei", + "errIncorrectUsernameOrPassword": "Incorrect username or password", + "errMustBeAtLeast": "Must be at least {n}", + "@errMustBeAtLeast": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeAtMost": "Must be at most {n}", + "@errMustBeAtMost": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeInteger": "Must be an integer", + "exit": "退出", + "exitTryMode": "Exit try mode", + "find": "查找", + "findTask": "Find task", + "forceCounting": "Force counting", + "foxwqDesc": "The most popular server in China and the world.", + "foxwqName": "Fox Weiqi", + "gameInfo": "Game info", + "gameRecord": "Game record", + "gradingExam": "Grading exam", + "handicap": "让子数", + "help": "帮助", + "helpDialogCollections": "Collections are classic, curated sets of high-quality tasks which hold special value together as a training resource.\n\nThe main goal is to solve a collection with a high success rate. A secondary goal is to solve it as fast as possible.\n\nTo start or continue solving a collection, slide left on the collection tile while in portrait mode or click the Start/Continue buttons while in landscape mode.", + "helpDialogEndgameExam": "- Endgame exams are sets of 10 endgame tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.", + "helpDialogGradingExam": "- Grading exams are sets of 10 tasks and you have 45 seconds per task.\n\n- You pass the exam if you solve 8 or more correctly (80% success rate).\n\n- Passing the exam for a given rank unlocks the exam for the next rank.", + "helpDialogRankedMode": "- Solve tasks without a time limit.\n\n- Task difficulty increases according to how fast you solve them.\n\n- Focus on solving correctly and reach the highest rank possible.", + "helpDialogTimeFrenzy": "- You have 3 minutes to solve as many tasks as possible.\n\n- Tasks get increasingly difficult as you solve them.\n\n- If you make 3 mistakes, you are out.", + "home": "主界面", + "komi": "Komi", + "language": "语言", + "leave": "Leave", + "light": "浅色", + "login": "登录", + "logout": "退出", + "long": "Long", + "mMinutes": "{m}min", + "@mMinutes": { + "placeholders": { + "m": { + "type": "int" + } + } + }, + "maxNumberOfMistakes": "Maximum number of mistakes", + "maxRank": "Max rank", + "medium": "Medium", + "minRank": "Min rank", + "minutes": "Minutes", + "month": "Month", + "msgCannotUseAIRefereeYet": "AI referee cannot be used yet", + "msgCannotUseForcedCountingYet": "Forced counting cannot be used yet", + "msgConfirmDeleteCollectionProgress": "Are you sure that you want to delete the previous attempt?", + "msgConfirmResignation": "Are you sure that you want to resign?", + "msgConfirmStopEvent": "Are you sure that you want to stop the {event}?", + "@msgConfirmStopEvent": { + "placeholders": { + "event": { + "type": "String" + } + } + }, + "msgDownloadingGame": "Downloading game", + "msgGameSavedTo": "Game saved to {path}", + "@msgGameSavedTo": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "msgPleaseWaitForYourTurn": "Please, wait for your turn", + "msgSearchingForGame": "Searching for a game...", + "msgTaskLinkCopied": "Task link copied.", + "msgWaitingForOpponentsDecision": "Waiting for your opponent's decision...", + "msgYouCannotPass": "You cannot pass", + "msgYourOpponentDisagreesWithCountingResult": "Your opponent disagrees with the counting result", + "msgYourOpponentRefusesToCount": "Your opponent refuses to count", + "msgYourOpponentRequestsAutomaticCounting": "Your opponent requests automatic counting. Do you agree?", + "myGames": "My games", + "myMistakes": "My mistakes", + "nTasks": "{count, plural, =0{No tasks} =1{1 task} other{{count} tasks}}", + "@nTasks": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "nTasksAvailable": "{count, plural, =0{No tasks available} =1{1 task available} other{{count} tasks available}}", + "@nTasksAvailable": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "newBestResult": "New best!", + "no": "No", + "none": "None", + "numberOfTasks": "Number of tasks", + "nxnBoardSize": "{n}×{n}", + "@nxnBoardSize": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "ogsDesc": "The premier online Go platform with tournaments, AI analysis, and a vibrant community.", + "ogsName": "Online Go Server", + "ok": "OK", + "pass": "Pass", + "password": "密码", + "play": "对局", + "promotionRequirements": "Promotion requirements", + "pxsByoyomi": "{p}×{s}s", + "@pxsByoyomi": { + "placeholders": { + "p": { + "type": "int" + }, + "s": { + "type": "int" + } + } + }, + "rank": "Rank", + "rankedMode": "Ranked mode", + "recentRecord": "Recent record", + "register": "注册", + "resign": "Resign", + "responseDelay": "响应延迟", + "responseDelayDesc": "解题时响应前的延迟时长", + "responseDelayLong": "长", + "responseDelayMedium": "中", + "responseDelayNone": "无", + "responseDelayShort": "短", + "result": "结果", + "rules": "规则", + "rulesChinese": "中国规则", + "rulesJapanese": "日本规则", + "rulesKorean": "韩国规则", + "save": "保存", + "saveSGF": "保存SGF", + "seconds": "秒", + "settings": "设置", + "short": "Short", + "showCoordinates": "显示坐标", + "simple": "普通", + "sortModeDifficult": "Difficult", + "sortModeRecent": "Recent", + "sound": "声音", + "start": "开始", + "statistics": "Statistics", + "statsDateColumn": "Date", + "statsDurationColumn": "Time", + "statsTimeColumn": "Time", + "stoneShadows": "棋子阴影", + "stones": "Stones", + "subtopic": "Subtopic", + "system": "系统", + "task": "任务", + "taskCorrect": "Correct", + "taskNext": "Next", + "taskRedo": "Redo", + "taskSource": "Task source", + "taskSourceFromMyMistakes": "From my mistakes", + "taskSourceFromTaskTopic": "From task topic", + "taskSourceFromTaskTypes": "From task types", + "taskTag_afterJoseki": "定式之后", + "taskTag_aiOpening": "AI布局", + "taskTag_aiVariations": "AI变化", + "taskTag_attack": "攻击", + "taskTag_attackAndDefenseInKo": "劫的攻防", + "taskTag_attackAndDefenseOfCuts": "切断的处理", + "taskTag_attackAndDefenseOfInvadingStones": "征子的攻防", + "taskTag_avoidKo": "避劫", + "taskTag_avoidMakingDeadShape": "避免被聚杀", + "taskTag_avoidTrap": "避开陷阱", + "taskTag_basicEndgame": "基础官子", + "taskTag_basicLifeAndDeath": "基本死活", + "taskTag_basicMoves": "基本行棋", + "taskTag_basicTesuji": "基本手筋", + "taskTag_beginner": "启蒙", + "taskTag_bend": "弯", + "taskTag_bentFour": "弯四", + "taskTag_bentFourInTheCorner": "盘角曲四", + "taskTag_bentThree": "弯三", + "taskTag_bigEyeLiberties": "大眼的气", + "taskTag_bigEyeVsSmallEye": "大眼杀小眼", + "taskTag_bigPoints": "大场", + "taskTag_blindSpot": "盲点", + "taskTag_breakEye": "破眼", + "taskTag_breakEyeInOneStep": "一步破眼", + "taskTag_breakEyeInSente": "先手破眼", + "taskTag_breakOut": "突围", + "taskTag_breakPoints": "破目", + "taskTag_breakShape": "破坏棋形", + "taskTag_bridgeUnder": "渡", + "taskTag_brilliantSequence": "一一妙手", + "taskTag_bulkyFive": "刀把五", + "taskTag_bump": "顶", + "taskTag_captureBySnapback": "扑吃", + "taskTag_captureInLadder": "征吃", + "taskTag_captureInOneMove": "一步吃子", + "taskTag_captureOnTheSide": "边线吃子", + "taskTag_captureToLive": "吃子做活", + "taskTag_captureTwoRecaptureOne": "打二还一", + "taskTag_capturingRace": "对杀", + "taskTag_capturingTechniques": "吃子技巧", + "taskTag_carpentersSquareAndSimilar": "金柜角及类似型", + "taskTag_chooseTheFight": "战斗的选择", + "taskTag_clamp": "夹", + "taskTag_clampCapture": "夹吃", + "taskTag_closeInCapture": "门吃", + "taskTag_combination": "组合手段", + "taskTag_commonLifeAndDeath": "常型死活", + "taskTag_compareSize": "比较大小", + "taskTag_compareValue": "价值比较", + "taskTag_completeKoToSecureEndgameAdvantage": "粘劫收后", + "taskTag_compositeProblems": "复合问题", + "taskTag_comprehensiveTasks": "综合", + "taskTag_connect": "连络", + "taskTag_connectAndDie": "接不归", + "taskTag_connectInOneMove": "一步连接", + "taskTag_contactFightTesuji": "接触战的手筋", + "taskTag_contactPlay": "靠", + "taskTag_corner": "角部常型", + "taskTag_cornerIsGoldSideIsSilverCenterIsGrass": "金角银边草肚皮", + "taskTag_counter": "应对", + "taskTag_counterAttack": "反击", + "taskTag_cranesNest": "乌龟不出头", + "taskTag_crawl": "爬", + "taskTag_createShortageOfLiberties": "导致气紧", + "taskTag_crossedFive": "梅花五", + "taskTag_cut": "断", + "taskTag_cut2": "分断", + "taskTag_cutAcross": "跨", + "taskTag_defendFromInvasion": "防守入侵", + "taskTag_defendPoints": "守目", + "taskTag_defendWeakPoint": "防范弱点", + "taskTag_descent": "立", + "taskTag_diagonal": "尖", + "taskTag_directionOfCapture": "吃子方向", + "taskTag_directionOfEscape": "逃子方向", + "taskTag_directionOfPlay": "方向选择", + "taskTag_doNotUnderestimateOpponent": "不要忽略对方的抵抗", + "taskTag_doubleAtari": "双吃", + "taskTag_doubleCapture": "双提", + "taskTag_doubleKo": "连环劫", + "taskTag_doubleSenteEndgame": "双先官子", + "taskTag_doubleSnapback": "双倒扑", + "taskTag_endgame": "官子", + "taskTag_endgameFundamentals": "基本收官", + "taskTag_endgameIn5x5": "5路官子", + "taskTag_endgameOn4x4": "4路官子", + "taskTag_endgameTesuji": "官子手筋", + "taskTag_engulfingAtari": "抱吃", + "taskTag_escape": "逃子", + "taskTag_escapeInOneMove": "一步逃子", + "taskTag_exploitShapeWeakness": "利用棋形弱点", + "taskTag_eyeVsNoEye": "有眼杀无眼", + "taskTag_fillNeutralPoints": "目与单官", + "taskTag_findTheRoot": "搜根", + "taskTag_firstLineBrilliantMove": "一路妙手", + "taskTag_flowerSix": "葡萄六", + "taskTag_goldenChickenStandingOnOneLeg": "金鸡独立", + "taskTag_groupLiberties": "棋子的气", + "taskTag_groupsBase": "棋子的根据地", + "taskTag_hane": "扳", + "taskTag_increaseEyeSpace": "扩大眼位", + "taskTag_increaseLiberties": "延气", + "taskTag_indirectAttack": "间接进攻", + "taskTag_influenceKeyPoints": "势力消长的要点", + "taskTag_insideKill": "聚杀", + "taskTag_insideMoves": "内部动手", + "taskTag_interestingTasks": "趣题", + "taskTag_internalLibertyShortage": "胀牯牛", + "taskTag_invadingTechnique": "入侵的手段", + "taskTag_invasion": "打入", + "taskTag_jGroupAndSimilar": "大猪嘴及类似型", + "taskTag_josekiFundamentals": "基本定式", + "taskTag_jump": "跳", + "taskTag_keepSente": "保留先手", + "taskTag_killAfterCapture": "提子后的杀着", + "taskTag_killByEyePointPlacement": "点杀", + "taskTag_knightsMove": "飞", + "taskTag_ko": "打劫", + "taskTag_kosumiWedge": "挤", + "taskTag_largeKnightsMove": "大飞", + "taskTag_largeMoyoFight": "大模样作战", + "taskTag_lifeAndDeath": "死活", + "taskTag_lifeAndDeathOn4x4": "4路死活", + "taskTag_lookForLeverage": "寻求借用", + "taskTag_looseLadder": "宽征", + "taskTag_lovesickCut": "相思断", + "taskTag_makeEye": "做眼", + "taskTag_makeEyeInOneStep": "一步做眼", + "taskTag_makeEyeInSente": "先手做眼", + "taskTag_makeKo": "做劫", + "taskTag_makeShape": "定形技巧", + "taskTag_middlegame": "中盘", + "taskTag_monkeyClimbingMountain": "猴子翻山", + "taskTag_mouseStealingOil": "老鼠偷油", + "taskTag_moveOut": "出头", + "taskTag_moveTowardsEmptySpace": "棋往宽处走", + "taskTag_multipleBrilliantMoves": "一二妙手", + "taskTag_net": "枷", + "taskTag_netCapture": "枷吃", + "taskTag_observeSubtleDifference": "注意细微差别", + "taskTag_occupyEncloseAndApproachCorner": "占角、守角和挂角", + "taskTag_oneStoneTwoPurposes": "一子两用", + "taskTag_opening": "布局", + "taskTag_openingChoice": "定式选择", + "taskTag_openingFundamentals": "布局基本下法", + "taskTag_orderOfEndgameMoves": "收束次序", + "taskTag_orderOfMoves": "行棋次序", + "taskTag_orderOfMovesInKo": "区分劫的先后手", + "taskTag_orioleCapturesButterfly": "黄莺扑蝶", + "taskTag_pincer": "夹击", + "taskTag_placement": "点", + "taskTag_plunderingTechnique": "搜刮的手段", + "taskTag_preventBambooJoint": "靠单", + "taskTag_preventBridgingUnder": "阻渡", + "taskTag_preventOpponentFromApproaching": "使对方不入", + "taskTag_probe": "试探应手", + "taskTag_profitInSente": "先手获利", + "taskTag_profitUsingLifeAndDeath": "利用死活问题获利", + "taskTag_push": "冲", + "taskTag_pyramidFour": "丁四", + "taskTag_realEyeAndFalseEye": "真眼和假眼", + "taskTag_rectangularSix": "板六", + "taskTag_reduceEyeSpace": "缩小眼位", + "taskTag_reduceLiberties": "紧气", + "taskTag_reduction": "侵消", + "taskTag_runWeakGroup": "出动残子", + "taskTag_sabakiAndUtilizingInfluence": "腾挪与借用", + "taskTag_sacrifice": "弃子", + "taskTag_sacrificeAndSqueeze": "滚打包收", + "taskTag_sealIn": "封锁", + "taskTag_secondLine": "二线型", + "taskTag_seizeTheOpportunity": "把握战机", + "taskTag_seki": "双活", + "taskTag_senteAndGote": "先手与后手", + "taskTag_settleShape": "整形", + "taskTag_settleShapeInSente": "先手定形", + "taskTag_shape": "棋形", + "taskTag_shapesVitalPoint": "棋形要点", + "taskTag_side": "边部常型", + "taskTag_smallBoardEndgame": "小棋盘官子", + "taskTag_snapback": "倒扑", + "taskTag_solidConnection": "接", + "taskTag_solidExtension": "长", + "taskTag_splitInOneMove": "一步分断", + "taskTag_splittingMove": "分投", + "taskTag_squareFour": "方四", + "taskTag_squeeze": "滚打", + "taskTag_standardCapturingRaces": "常型对杀", + "taskTag_standardCornerAndSideEndgame": "边角常型收束", + "taskTag_straightFour": "直四", + "taskTag_straightThree": "直三", + "taskTag_surroundTerritory": "围空", + "taskTag_symmetricShape": "左右同型", + "taskTag_techniqueForReinforcingGroups": "补棋的方法", + "taskTag_techniqueForSecuringTerritory": "地中的手段", + "taskTag_textbookTasks": "文字题", + "taskTag_thirdAndFourthLine": "三路和四路", + "taskTag_threeEyesTwoActions": "三眼两做", + "taskTag_threeSpaceExtensionFromTwoStones": "立二拆三", + "taskTag_throwIn": "扑", + "taskTag_tigersMouth": "虎", + "taskTag_tombstoneSqueeze": "大头鬼", + "taskTag_tripodGroupWithExtraLegAndSimilar": "小猪嘴及类似型", + "taskTag_twoHaneGainOneLiberty": "两扳长一气", + "taskTag_twoHeadedDragon": "盘龙眼", + "taskTag_twoSpaceExtension": "拆二", + "taskTag_typesOfKo": "区分劫的种类", + "taskTag_underTheStones": "倒脱靴", + "taskTag_underneathAttachment": "托", + "taskTag_urgentPointOfAFight": "战斗的急所", + "taskTag_urgentPoints": "急所", + "taskTag_useConnectAndDie": "利用接不归", + "taskTag_useCornerSpecialProperties": "利用角部特殊性", + "taskTag_useDescentToFirstLine": "利用一路硬腿", + "taskTag_useInfluence": "厚势的作用", + "taskTag_useOpponentsLifeAndDeath": "利用对方死活", + "taskTag_useShortageOfLiberties": "利用气紧", + "taskTag_useSnapback": "利用倒扑", + "taskTag_useSurroundingStones": "利用外围棋子", + "taskTag_vitalAndUselessStones": "要子与废子", + "taskTag_vitalPointForBothSides": "双方要点", + "taskTag_vitalPointForCapturingRace": "对杀要点", + "taskTag_vitalPointForIncreasingLiberties": "延气要点", + "taskTag_vitalPointForKill": "杀棋要点", + "taskTag_vitalPointForLife": "活棋要点", + "taskTag_vitalPointForReducingLiberties": "紧气要点", + "taskTag_wedge": "挖", + "taskTag_wedgingCapture": "挖吃", + "taskTimeout": "Timeout", + "taskTypeAppreciation": "Appreciation", + "taskTypeCapture": "Capture stones", + "taskTypeCaptureRace": "Capture race", + "taskTypeEndgame": "Endgame", + "taskTypeJoseki": "Joseki", + "taskTypeLifeAndDeath": "Life \u0026 death", + "taskTypeMiddlegame": "Middlegame", + "taskTypeOpening": "Opening", + "taskTypeTesuji": "Tesuji", + "taskTypeTheory": "Theory", + "taskWrong": "Wrong", + "tasksSolved": "Tasks solved", + "test": "Test", + "theme": "主题", + "thick": "粗线", + "timeFrenzy": "Time frenzy", + "timePerTask": "Time per task", + "today": "Today", + "tooltipAnalyzeWithAISensei": "Analyze with AI Sensei", + "tooltipDownloadGame": "Download game", + "topic": "Topic", + "topicExam": "Topic exam", + "topics": "Topics", + "train": "练习", + "trainingAvgTimePerTask": "Avg time per task", + "trainingFailed": "Failed", + "trainingMistakes": "Mistakes", + "trainingPassed": "Passed", + "trainingTotalTime": "Total time", + "tryCustomMoves": "Try custom moves", + "tygemDesc": "The most popular server in Korea and one of the most popular in the world.", + "tygemName": "Tygem Baduk", + "type": "Type", + "ui": "UI", + "userInfo": "用户信息", + "username": "用户名", + "voice": "Voice", + "week": "Week", + "white": "White", + "yes": "Yes" +} diff --git a/lib/local_board_page.dart b/lib/local_board_page.dart index e93d53e2..8f3b3f2a 100644 --- a/lib/local_board_page.dart +++ b/lib/local_board_page.dart @@ -1,11 +1,15 @@ +import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' show dirname; import 'package:share_plus/share_plus.dart'; import 'package:wqhub/audio/audio_controller.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/save_sgf_form.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/wq/annotated_game_tree.dart'; @@ -17,6 +21,8 @@ import 'package:wqhub/wq/handicap.dart'; import 'package:wqhub/wq/wq.dart' as wq; class LocalBoardPage extends StatefulWidget { + static const routeName = '/local_board'; + const LocalBoardPage({super.key}); @override @@ -40,6 +46,7 @@ class _LocalBoardPageState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final borderSize = 1.5 * (Theme.of(context).textTheme.labelMedium?.fontSize ?? 0); final border = context.settings.showCoordinates @@ -84,7 +91,7 @@ class _LocalBoardPageState extends State { return Scaffold( appBar: AppBar( - title: Text('Board'), + title: Text(loc.board), actions: [ Switch( thumbIcon: variationThumbIcon, @@ -131,9 +138,7 @@ class _LocalBoardPageState extends State { ? AnnotationMode.variation : AnnotationMode.mainline) != null) { - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } + AudioController().playForNode(_gameTree.curNode); setState(() { _turn = _turn.opposite; }); @@ -168,29 +173,45 @@ class _LocalBoardPageState extends State { whiteRank: res.whiteRank, result: res.result, ); - final filename = res.suggestedFilename(); - if (Platform.isLinux) { - final downloadDir = await getDownloadsDirectory(); - final f = File('${downloadDir?.path}/$filename'); - await f.writeAsString(sgfData); - if (context.mounted) { + final fileName = res.suggestedFilename(); + + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + final savePath = await FilePicker.platform.saveFile( + fileName: fileName, + initialDirectory: context.settings.getSaveDirectory(), + ); + if (savePath != null) { + final f = File(savePath); + await f.writeAsBytes(utf8.encode(sgfData)); ScaffoldMessenger.of(context).removeCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('SGF saved to Downloads folder'), + content: Text('SGF saved to $savePath'), showCloseIcon: true, behavior: SnackBarBehavior.floating, )); + context.settings.saveDirectory = dirname(savePath); } } else { - await Share.shareXFiles( - [ - XFile.fromData( - Uint8List.fromList(sgfData.codeUnits), - mimeType: 'application/x-go-sgf', - ) - ], - text: filename, - fileNameOverrides: [filename]); + Rect? sharePositionOrigin; + if (defaultTargetPlatform == TargetPlatform.iOS) { + DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + IosDeviceInfo iosInfo = await deviceInfo.iosInfo; + if (iosInfo.model.toLowerCase().contains('ipad')) { + final box = context.findRenderObject() as RenderBox?; + sharePositionOrigin = box!.localToGlobal(Offset.zero) & box.size; + } + } + final params = ShareParams( + files: [ + XFile.fromData( + utf8.encode(sgfData), + mimeType: 'application/x-go-sgf', + ) + ], + fileNameOverrides: [fileName], + sharePositionOrigin: sharePositionOrigin, + ); + await SharePlus.instance.share(params); } } } @@ -219,6 +240,7 @@ class _LocalBoardMenuState extends State<_LocalBoardMenu> { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return MenuAnchor( childFocusNode: _buttonFocusNode, menuChildren: [ @@ -238,10 +260,10 @@ class _LocalBoardMenuState extends State<_LocalBoardMenu> { leadingIcon: Icon(_boardSize == size ? Icons.check_box : Icons.check_box_outline_blank), - child: Text('${size}x$size'), + child: Text(loc.nxnBoardSize(size)), ) ], - child: const Text('Board size'), + child: Text(loc.boardSize), ), SubmenuButton( menuChildren: [ @@ -261,10 +283,9 @@ class _LocalBoardMenuState extends State<_LocalBoardMenu> { child: Text('$h'), ) ], - child: const Text('Handicap'), + child: Text(loc.handicap), ), - MenuItemButton( - onPressed: widget.onSaveSgf, child: const Text('Save SGF')), + MenuItemButton(onPressed: widget.onSaveSgf, child: Text(loc.saveSGF)), ], builder: (context, controller, child) { return IconButton( diff --git a/lib/main.dart b/lib/main.dart index 5dba8141..3235fecf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,13 +5,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wqhub/audio/audio_controller.dart'; import 'package:wqhub/settings/settings.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/stats/stats_db.dart'; import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/version_patch.dart'; import 'package:wqhub/weiqi_hub_app.dart'; Future main() async { @@ -23,18 +23,22 @@ Future main() async { } runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); + final sharedPreferences = await SharedPreferencesWithCache.create( cacheOptions: const SharedPreferencesWithCacheOptions()); - final downloadsDir = await getDownloadsDirectory(); - final appDocsDir = await getApplicationDocumentsDirectory(); - sharedPreferences.setString( - Settings.defaultSaveDirKey, downloadsDir?.path ?? appDocsDir.path); + final settings = Settings(sharedPreferences); + + // Initialize singletons await Future.wait([ SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), - AudioController.init(), + AudioController.init(settings), TaskRepository.init(), StatsDB.init(), ]); + + // Apply version patches + applyVersionPatch(settings); + runApp( SharedPreferencesInheritedWidget( sharedPreferences: sharedPreferences, diff --git a/lib/main_page.dart b/lib/main_page.dart index 98514e43..a4c7a91c 100644 --- a/lib/main_page.dart +++ b/lib/main_page.dart @@ -1,13 +1,18 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:wqhub/game_client/test_game_client.dart'; +import 'package:wqhub/game_client/game_client_list.dart'; +import 'package:wqhub/help/ranked_mode_help_dialog.dart'; +import 'package:wqhub/help/time_frenzy_help_dialog.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/main_page_bottom_navigation_bar.dart'; import 'package:wqhub/main_page_navigation_rail.dart'; import 'package:wqhub/play/server_card.dart'; +import 'package:wqhub/section_button.dart'; import 'package:wqhub/settings/settings_button.dart'; import 'package:wqhub/settings/settings_page.dart'; +import 'package:wqhub/settings/settings_route_arguments.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/train/collections_page.dart'; +import 'package:wqhub/train/custom_exam_selection_page.dart'; import 'package:wqhub/train/endgame_exam_selection_page.dart'; import 'package:wqhub/train/my_mistakes_page.dart'; import 'package:wqhub/train/ranked_mode_page.dart'; @@ -25,17 +30,27 @@ import 'package:wqhub/window_class_aware_state.dart'; enum MainPageDestination { home, play, train } +class MainRouteArguments { + final MainPageDestination destination; + final Function() rebuildApp; + + const MainRouteArguments( + {required this.destination, required this.rebuildApp}); +} + class MainPage extends StatefulWidget { - final Function() reloadAppTheme; + final MainPageDestination destination; + final Function() rebuildApp; - const MainPage({super.key, required this.reloadAppTheme}); + const MainPage( + {super.key, required this.destination, required this.rebuildApp}); @override State createState() => _MainPageState(); } class _MainPageState extends WindowClassAwareState { - MainPageDestination _selectedDestination = MainPageDestination.home; + late MainPageDestination _selectedDestination = widget.destination; @override Widget build(BuildContext context) { @@ -44,7 +59,7 @@ class _MainPageState extends WindowClassAwareState { ? AppBar( title: Text('WeiqiHub'), actions: [ - SettingsButton(reloadAppTheme: widget.reloadAppTheme), + SettingsButton(reloadAppTheme: widget.rebuildApp), ], ) : null, @@ -71,12 +86,11 @@ class _MainPageState extends WindowClassAwareState { heroTag: null, child: Icon(Icons.settings), onPressed: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => - SettingsPage(reloadAppTheme: widget.reloadAppTheme), - ), + SettingsPage.routeName, + arguments: + SettingsRouteArguments(rebuildApp: widget.rebuildApp), ); }, ), @@ -108,18 +122,29 @@ class _Home extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Center( - child: FloatingActionButton.extended( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const LocalBoardPage(), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 400), + child: GridView( + padding: EdgeInsets.all(8), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: 1, + childAspectRatio: 2.5, + ), + children: [ + SectionButton( + icon: Icons.grid_on, + label: loc.board, + onPressed: () { + Navigator.pushNamed(context, LocalBoardPage.routeName); + }, ), - ); - }, - icon: Icon(Icons.grid_on), - label: const Text('Board'), + ], + ), ), ); } @@ -133,7 +158,8 @@ class _Play extends StatelessWidget { return Center( child: Column( children: [ - if (kDebugMode) ServerCard(gameClient: TestGameClient()), + for (final gameClient in gameClients) + ServerCard(gameClient: gameClient), ], ), ); @@ -147,6 +173,7 @@ class _Train extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 800), @@ -160,103 +187,93 @@ class _Train extends StatelessWidget { childAspectRatio: 2.5, ), children: [ - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.verified, + label: loc.gradingExam, onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => GradingExamSelectionPage(), - ), - ); + Navigator.pushNamed( + context, GradingExamSelectionPage.routeName); }, - icon: Icon(Icons.verified), - label: const Text('Grading exam'), ), - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.verified, + label: loc.endgameExam, onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => EndgameExamSelectionPage(), - ), - ); + Navigator.pushNamed( + context, EndgameExamSelectionPage.routeName); }, - icon: Icon(Icons.verified), - label: const Text('Endgame exam'), ), - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.bolt, + label: loc.timeFrenzy, onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: TimeFrenzyPage( - taskSource: BlackToPlaySource( - source: TimeFrenzyTaskSource(), - blackToPlay: context.settings.alwaysBlackToPlay, - ), + if (context.settings.showTimeFrenzyHelp) { + showDialog( + context: context, + builder: (context) => TimeFrenzyHelpDialog(), + ); + } else { + Navigator.pushNamed( + context, + TimeFrenzyPage.routeName, + arguments: TimeFrenzyRouteArguments( + taskSource: BlackToPlaySource( + source: TimeFrenzyTaskSource(), + blackToPlay: context.settings.alwaysBlackToPlay, ), ), - ), - ); + ); + } }, - icon: Icon(Icons.bolt), - label: const Text('Time frenzy'), ), - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.trending_up, + label: loc.rankedMode, onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: RankedModePage( - taskSource: BlackToPlaySource( - source: RankedModeTaskSource( - context.stats.rankedModeRank), - blackToPlay: context.settings.alwaysBlackToPlay, - ), + if (context.settings.showRankedModeHelp) { + showDialog( + context: context, + builder: (context) => RankedModeHelpDialog(), + ); + } else { + Navigator.pushNamed( + context, + RankedModePage.routeName, + arguments: RankedModeRouteArguments( + taskSource: BlackToPlaySource( + source: + RankedModeTaskSource(context.stats.rankedModeRank), + blackToPlay: context.settings.alwaysBlackToPlay, ), ), - ), - ); + ); + } }, - icon: Icon(Icons.trending_up), - label: const Text('Ranked mode'), ), - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.book, + label: loc.collections, onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CollectionsPage(), - ), - ); + Navigator.pushNamed(context, CollectionsPage.routeName); }, - icon: Icon(Icons.book), - label: const Text('Collections'), ), - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.category, + label: loc.topics, onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TagsPage(), - ), - ); + Navigator.pushNamed(context, TagsPage.routeName); }, - icon: Icon(Icons.category), - label: const Text('Topics'), ), - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.tune, + label: loc.customExam, + onPressed: () { + Navigator.pushNamed(context, CustomExamSelectionPage.routeName); + }, + ), + SectionButton( + icon: Icons.search, + label: loc.findTask, onPressed: () { showDialog<(Task?, bool)>( context: context, @@ -267,13 +284,14 @@ class _Train extends StatelessWidget { if (dismissed) return; if (task != null) { if (context.mounted) { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => SingleTaskPage( - task: context.settings.alwaysBlackToPlay - ? task.withBlackToPlay() - : task)), + SingleTaskPage.routeName, + arguments: SingleTaskRouteArguments( + task: context.settings.alwaysBlackToPlay + ? task.withBlackToPlay() + : task, + ), ); } } else { @@ -296,34 +314,20 @@ class _Train extends StatelessWidget { } }); }, - icon: Icon(Icons.search), - label: const Text('Find task'), ), - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.sentiment_very_dissatisfied, + label: loc.myMistakes, onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MyMistakesPage(), - ), - ); + Navigator.pushNamed(context, MyMistakesPage.routeName); }, - icon: Icon(Icons.sentiment_very_dissatisfied), - label: const Text('My mistakes'), ), - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.query_stats, + label: loc.statistics, onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TrainStatsPage(), - ), - ); + Navigator.pushNamed(context, TrainStatsPage.routeName); }, - icon: Icon(Icons.query_stats), - label: const Text('Statistics'), ), ], ), @@ -348,27 +352,28 @@ class _FindTaskDialogState extends State<_FindTaskDialog> { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return AlertDialog( icon: Icon(Icons.search), - title: const Text('Find task'), + title: Text(loc.findTask), content: TextField( controller: controller, decoration: InputDecoration( border: OutlineInputBorder(), - hintText: 'Enter the task link', + hintText: loc.enterTaskLink, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, (null, true)), - child: const Text('Cancel'), + child: Text(loc.cancel), ), TextButton( onPressed: () { Navigator.pop(context, (TaskRepository().readByUri(controller.text.trim()), false)); }, - child: const Text('Find'), + child: Text(loc.find), ), ], ); diff --git a/lib/main_page_bottom_navigation_bar.dart b/lib/main_page_bottom_navigation_bar.dart index 472b2cc7..c7f7af12 100644 --- a/lib/main_page_bottom_navigation_bar.dart +++ b/lib/main_page_bottom_navigation_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/main_page.dart'; class MainPageBottomNavigationBar extends StatefulWidget { @@ -28,6 +29,7 @@ class _MainPageBottomNavigationBarState @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return BottomNavigationBar( currentIndex: _selectedDestination.index, onTap: (index) { @@ -38,18 +40,18 @@ class _MainPageBottomNavigationBarState widget.onDestinationSelected(_selectedDestination); } }, - items: const [ + items: [ BottomNavigationBarItem( icon: Icon(Icons.home), - label: 'Home', + label: loc.home, ), BottomNavigationBarItem( icon: Icon(Icons.sports_esports), - label: 'Play', + label: loc.play, ), BottomNavigationBarItem( icon: Icon(Icons.fitness_center), - label: 'Train', + label: loc.train, ), ], ); diff --git a/lib/main_page_navigation_rail.dart b/lib/main_page_navigation_rail.dart index 687ab97f..1af4cce9 100644 --- a/lib/main_page_navigation_rail.dart +++ b/lib/main_page_navigation_rail.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/main_page.dart'; class MainPageNavigationRail extends StatefulWidget { @@ -26,6 +27,7 @@ class _MainPageNavigationRailState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return NavigationRail( selectedIndex: _selectedDestination.index, onDestinationSelected: (index) { @@ -37,18 +39,18 @@ class _MainPageNavigationRailState extends State { } }, labelType: NavigationRailLabelType.all, - destinations: const [ + destinations: [ NavigationRailDestination( icon: Icon(Icons.home), - label: Text('Home'), + label: Text(loc.home), ), NavigationRailDestination( icon: Icon(Icons.sports_esports), - label: Text('Play'), + label: Text(loc.play), ), NavigationRailDestination( icon: Icon(Icons.fitness_center), - label: Text('Train'), + label: Text(loc.train), ), ], ); diff --git a/lib/parse/gib/gib.dart b/lib/parse/gib/gib.dart index 933d2cc2..7d8ce428 100644 --- a/lib/parse/gib/gib.dart +++ b/lib/parse/gib/gib.dart @@ -1,6 +1,5 @@ import 'dart:convert'; - -import 'package:wqhub/parse/parse_ext.dart'; +import 'package:petitparser/petitparser.dart'; class GibGameCmd { final String cmd; @@ -40,65 +39,108 @@ class Gib { 'WIT', }; - final Map>> headers; + final Map> headers; final List game; Gib._({required this.headers, required this.game}); + static final _parser = _GibDefinition().build(); + factory Gib.parse(List gib) { - final (it, headers) = _parseHeaders(gib); - final (_, game) = _parseGame(it); - final cmds = game.split('\n').map((s) { - final parts = s.trim().split(' '); - if (parts.isNotEmpty && commands.contains(parts.first)) { - return GibGameCmd(cmd: parts.first, args: parts.sublist(1)); - } - return GibGameCmd(cmd: '', args: parts); - }); - return Gib._( - headers: headers, - game: cmds.toList(growable: false), - ); + final str = utf8.decode(gib, allowMalformed: true); + return _parser.parse(str).value; } +} - static (Iterable, Map>>) _parseHeaders( - Iterable it) { - return it.skipSpace().tag(r'\HS', r'\HE', (it) { - Map>> headers = {}; - while (utf8.decode(it.take(3).toList(), allowMalformed: true) != r'\HE') { - final (nextIt, (name, hdr)) = _parseHeader(it); - headers[name] = hdr; - it = nextIt.skipSpace(); - } - return (it, headers); - }); - } +class _GibDefinition extends GrammarDefinition { + @override + Parser start() => (ref0(headersSection) & ref0(gameSection)) + .map((tuple) => Gib._( + headers: tuple[0], + game: tuple[1], + )); - static (Iterable, (String, Map>)) _parseHeader( - Iterable it) { - return it.skipSpace().tag(r'\[', r'\]', (it) { - final (itAfterName, name) = it.propName(); - it = itAfterName.matchEqual(); - Map> header = {}; - while (!it.isBackslash()) { - final (itAfterKey, key) = it.collectUntil(':'.codeUnitAt(0)); - it = itAfterKey.matchColon(); - final (itAfterValue, value) = - it.collectUntilCond((it) => it.isComma() || it.isBackslash()); - it = itAfterValue; - header[utf8.decode(key)] = value; - if (it.isBackslash()) break; - it = it.matchComma(); - } - return (it, (name, header)); - }); - } + // Whitespace helpers + Parser ws() => (whitespace() | newline()).star(); + + // Headers section: \HS ... \HE + Parser>> headersSection() => + ref0(header) + .star() + .skip(before: string(r'\HS') & ref0(ws), after: string(r'\HE') & ref0(ws)) + .map((list) => {for (final (name, hdr) in list) name: hdr}); + + // Single header: \[HEADERNAME=key1:value1,key2:value2,simplevalue\] + Parser<(String, Map)> header() => + ref0(headerContent) + .skip( + before: string(r'\[') & ref0(ws), + after: string(r'\]') & ref0(ws), + ) + .cast<(String, Map)>(); + + Parser<(String, Map)> headerContent() => + (ref0(headerName) & char('=') & ref0(headerValues)).map((tuple) { + return (tuple[0] as String, tuple[2] as Map); + }); + + Parser headerName() => uppercase().plus().flatten(); + + // Header values: can be key:value pairs OR just plain values + Parser> headerValues() => + ref0(headerValue) + .separatedBy(char(','), includeSeparators: false) + .map((list) { + final map = {}; + for (int i = 0; i < list.length; i++) { + final entry = list[i]; + if (entry is MapEntry) { + map[entry.key] = entry.value; + } else { + // Plain value without key - use index as key + map[i.toString()] = entry as String; + } + } + return map; + }); + + Parser headerValue() => + ref0(keyValuePair) | ref0(plainValue); + + Parser> keyValuePair() => + (ref0(keyName) & char(':') & ref0(valueName)).map((tuple) { + return MapEntry(tuple[0] as String, tuple[2] as String); + }); + + Parser plainValue() => + pattern(r'^:,\]\\').plus().flatten(); + + // Key name: any chars except :,\] + Parser keyName() => + pattern(r'^:,\]\\').plus().flatten(); + + // Value name: any chars except ,\] (can be empty, can contain colons for URLs) + Parser valueName() => + pattern(r'^,\]\\').star().flatten(); - static (Iterable, String) _parseGame(Iterable it) { - return it.skipSpace().tag(r'\GS', r'\GE', (it) { - final (itAfterGame, data) = - it.skipSpace().collectUntil(r'\'.codeUnitAt(0)); - return (itAfterGame, utf8.decode(data).trim()); - }); + // Game section: \GS ... \GE + Parser> gameSection() { + final rawContent = pattern(r'^\').star().flatten(); + return rawContent + .skip(before: string(r'\GS'), after: string(r'\GE')) + .map((content) { + return content + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .map((line) { + final parts = line.split(RegExp(r'\s+')); + if (parts.isNotEmpty && Gib.commands.contains(parts.first)) { + return GibGameCmd(cmd: parts.first, args: parts.sublist(1)); + } + return GibGameCmd(cmd: '', args: parts); + }) + .toList(growable: false); + }); } } diff --git a/lib/parse/parse_ext.dart b/lib/parse/parse_ext.dart deleted file mode 100644 index 1fd3212b..00000000 --- a/lib/parse/parse_ext.dart +++ /dev/null @@ -1,75 +0,0 @@ -extension ParseExt on Iterable { - static final _space = ' '.codeUnitAt(0); - static final _tab = '\t'.codeUnitAt(0); - static final _newLine = '\n'.codeUnitAt(0); - static final _cr = '\r'.codeUnitAt(0); - static final _openParen = '('.codeUnitAt(0); - static final _openBracket = '['.codeUnitAt(0); - static final _closedBracket = ']'.codeUnitAt(0); - - Iterable skipSpace() { - return skipWhile( - (c) => c == _space || c == _tab || c == _newLine || c == _cr); - } - - Iterable match(int want) { - if (first != want) { - throw FormatException('expected "$want", but got "$first"'); - } - return skip(1); - } - - Iterable matchSemicolon() => match(';'.codeUnitAt(0)); - Iterable matchColon() => match(':'.codeUnitAt(0)); - Iterable matchEqual() => match('='.codeUnitAt(0)); - Iterable matchComma() => match(','.codeUnitAt(0)); - - Iterable matchString(String s) => - s.codeUnits.fold(this, (it, i) => it.match(i)); - - (Iterable, T) tag( - String open, String close, (Iterable, T) Function(Iterable) f) { - final (it, t) = f(matchString(open)); - return (it.skipSpace().matchString(close), t); - } - - (Iterable, T) parentheses( - (Iterable, T) Function(Iterable) f) => - tag('(', ')', f); - - (Iterable, T) brackets( - (Iterable, T) Function(Iterable) f) => - tag('[', ']', f); - - (Iterable, String) propName() { - var it = this; - final sb = StringBuffer(); - while (it.isUpper()) { - sb.writeCharCode(it.first); - it = it.skip(1); - } - return (it, sb.toString()); - } - - (Iterable, List) collectUntil(int ch) => - collectUntilCond((it) => it.first == ch); - - (Iterable, List) collectUntilCond( - bool Function(Iterable) cond) { - var it = this; - final ret = []; - while (!cond(it)) { - ret.add(it.first); - it = it.skip(1); - } - return (it, ret); - } - - bool isLower() => 'a'.codeUnitAt(0) <= first && first <= 'z'.codeUnitAt(0); - bool isUpper() => 'A'.codeUnitAt(0) <= first && first <= 'Z'.codeUnitAt(0); - bool isOpenParen() => first == _openParen; - bool isOpenBracket() => first == _openBracket; - bool isClosedBracket() => first == _closedBracket; - bool isComma() => first == ','.codeUnitAt(0); - bool isBackslash() => first == r'\'.codeUnitAt(0); -} diff --git a/lib/parse/sgf/sgf.dart b/lib/parse/sgf/sgf.dart index c66efa3d..fb28fd8f 100644 --- a/lib/parse/sgf/sgf.dart +++ b/lib/parse/sgf/sgf.dart @@ -30,7 +30,7 @@ class _SgfDefinition extends GrammarDefinition { Parser gameTree() => (ref0(nodeSeq) & ref0(gameTree).starSeparated(ref0(space))) - .skip(before: char('('), after: char(')')) + .skip(before: char('(').trim(), after: char(')').trim()) .map((l) => SgfTree( nodes: l[0], children: l[1].elements, @@ -53,8 +53,13 @@ class _SgfDefinition extends GrammarDefinition { Parser> propValues() => ref0(propValue).plusSeparated(ref0(space)).map((sl) => sl.elements); - Parser propValue() => (any().starLazy(char(']')).flatten()) - .skip(before: char('['), after: char(']')); + Parser propValue() => + ((char('\\') & char(']')).map((_) => ']') // handle escaped ] + | + (char(']').not() & any()).pick(1)) // any char except ] + .star() + .map((parts) => parts.join()) + .skip(before: char('['), after: char(']')); Parser space() => (whitespace() | newline()).star(); } diff --git a/lib/play/automatch_page.dart b/lib/play/automatch_page.dart index a4b3d3fc..21ad3be5 100644 --- a/lib/play/automatch_page.dart +++ b/lib/play/automatch_page.dart @@ -3,10 +3,20 @@ import 'package:wqhub/audio/audio_controller.dart'; import 'package:wqhub/game_client/automatch_preset.dart'; import 'package:wqhub/game_client/game.dart'; import 'package:wqhub/game_client/game_client.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/play/game_page.dart'; -import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; + +class AutomatchRouteArguments { + final GameClient gameClient; + final AutomatchPreset preset; + + const AutomatchRouteArguments( + {required this.gameClient, required this.preset}); +} class AutomatchPage extends StatefulWidget { + static const routeName = '/play/automatch'; + final GameClient gameClient; final AutomatchPreset preset; @@ -25,17 +35,14 @@ class _AutomatchPageState extends State { void initState() { _findGame.then((game) { if (context.mounted) { - if (context.settings.sound) AudioController().startToPlay(); - Navigator.pushReplacement( + AudioController().startToPlay(); + Navigator.pushReplacementNamed( context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: GamePage( - serverFeatures: widget.gameClient.serverFeatures, - game: game, - ), - ), + GamePage.routeName, + arguments: GameRouteArguments( + serverFeatures: widget.gameClient.serverFeatures, + game: game, + gameListener: null, ), ); } @@ -54,9 +61,10 @@ class _AutomatchPageState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( - title: const Text('Auto-Match'), + title: Text(loc.autoMatch), automaticallyImplyLeading: false, ), body: FutureBuilder( @@ -67,7 +75,7 @@ class _AutomatchPageState extends State { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text('Searching for a game...'), + Text(loc.msgSearchingForGame), SizedBox(height: 16), CircularProgressIndicator(), SizedBox(height: 16), @@ -75,7 +83,7 @@ class _AutomatchPageState extends State { onPressed: () { widget.gameClient.stopAutomatch(); }, - child: const Text('Cancel')), + child: Text(loc.cancel)), ], ), ); diff --git a/lib/play/counting_result_bottom_sheet.dart b/lib/play/counting_result_bottom_sheet.dart index 7585751e..dc7dbafc 100644 --- a/lib/play/counting_result_bottom_sheet.dart +++ b/lib/play/counting_result_bottom_sheet.dart @@ -5,7 +5,7 @@ class CountingResultBottomSheet extends StatelessWidget { final wq.Color winner; final double scoreLead; final Function() onAccept; - final Function() onReject; + final Function()? onReject; const CountingResultBottomSheet({ super.key, diff --git a/lib/play/game_page.dart b/lib/play/game_page.dart index 100f33d8..1d85c28c 100644 --- a/lib/play/game_page.dart +++ b/lib/play/game_page.dart @@ -13,6 +13,7 @@ import 'package:wqhub/game_client/game_result.dart'; import 'package:wqhub/game_client/server_features.dart'; import 'package:wqhub/game_client/time_state.dart'; import 'package:wqhub/game_client/user_info.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/play/counting_result_bottom_sheet.dart'; import 'package:wqhub/play/game_navigation_bar.dart'; import 'package:wqhub/play/gameplay_bar.dart'; @@ -30,6 +31,24 @@ import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/coordinate_style.dart'; import 'package:wqhub/wq/wq.dart' as wq; +abstract class GameListener { + void onSetup(Game game); + void onPass(String gid); + void onMove(String gid, wq.Move move); + void onResult(String gid, GameResult result); +} + +class GameRouteArguments { + final ServerFeatures serverFeatures; + final Game game; + final GameListener? gameListener; + + const GameRouteArguments( + {required this.serverFeatures, + required this.game, + required this.gameListener}); +} + /* State machine for the GamePage ================================================================================ @@ -71,13 +90,17 @@ enum GameState { } class GamePage extends StatefulWidget { + static const routeName = '/play/game'; + final ServerFeatures serverFeatures; final Game game; + final GameListener? gameListener; const GamePage({ super.key, required this.serverFeatures, required this.game, + this.gameListener, }); @override @@ -112,17 +135,31 @@ class _GamePageState extends State { _turn = mv.col.opposite; } - widget.game.moves().forEach((mv) => mv == null ? onPass() : onMove(mv)); - widget.game.result().then(onGameResult, onError: onGameError); + widget.game.moves().forEach((mv) { + if (mv == null) { + onPass(); + widget.gameListener?.onPass(widget.game.id); + } else { + onMove(mv); + widget.gameListener?.onMove(widget.game.id, mv); + } + }); + widget.game.result().then((res) { + onGameResult(res); + widget.gameListener?.onResult(widget.game.id, res); + }, onError: onGameError); widget.game .automaticCountingResponses() .forEach(onAgreeToAutomaticCounting); widget.game.countingResultResponses().forEach(onAcceptCountingResult); widget.game.countingResults().forEach(onCountingResult); + + widget.gameListener?.onSetup(widget.game); } @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; // Board @@ -324,7 +361,7 @@ class _GamePageState extends State { floatingActionButton: (wideLayout && _state == GameState.over) ? FloatingActionButton.large( onPressed: onLeaveClicked, - tooltip: 'Leave game', + tooltip: loc.leave, child: Icon(Icons.logout), ) : null, @@ -347,9 +384,7 @@ class _GamePageState extends State { : AnnotationMode.mainline); if (node == null) return; - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } + AudioController().playForNode(_gameTree.curNode); if (_state == GameState.playing) { // If we are playing, send the move command to the game client @@ -388,9 +423,7 @@ class _GamePageState extends State { final node = _gameTree.moveAnnotated(mv, mode: AnnotationMode.mainline); if (node == null) return; - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } + AudioController().playForNode(_gameTree.curNode); setState(() { _turn = _turn.opposite; @@ -398,12 +431,13 @@ class _GamePageState extends State { } void onPassClicked() { + final loc = AppLocalizations.of(context)!; // A pass can only processed in one of the following cases: // - game is ongoing and it's our turn if (_state != GameState.playing) return; if (_turn != widget.game.myColor) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Please wait for your turn'), + content: Text(loc.msgPleaseWaitForYourTurn), showCloseIcon: true, )); return; @@ -423,7 +457,7 @@ class _GamePageState extends State { }); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('You cannot pass'), + content: Text(loc.msgYouCannotPass), showCloseIcon: true, )); } @@ -436,6 +470,7 @@ class _GamePageState extends State { } void onPass() { + final loc = AppLocalizations.of(context)!; switch (_state) { case GameState.over: // If the game is already over, ignore move events. Should not happen. @@ -443,11 +478,11 @@ class _GamePageState extends State { case GameState.playing: // If we are playing, assume our opponent passed. ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Pass'), + content: Text(loc.pass), behavior: SnackBarBehavior.floating, showCloseIcon: true, )); - if (context.settings.sound) AudioController().pass(); + AudioController().pass(); setState(() { _state = GameState.playing; _turn = _turn.opposite; @@ -455,11 +490,11 @@ class _GamePageState extends State { case GameState.acking: // If we are acking, assume this is the ack for our pass request. ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Pass'), + content: Text(loc.pass), behavior: SnackBarBehavior.floating, showCloseIcon: true, )); - if (context.settings.sound) AudioController().pass(); + AudioController().pass(); setState(() { _state = GameState.playing; _turn = _turn.opposite; @@ -472,17 +507,20 @@ class _GamePageState extends State { void onResignClicked() { showDialog( context: context, - builder: (context) => ConfirmDialog( - title: 'Confirm', - content: 'Are you sure that you want to resign?', - onYes: () { - widget.game.resign(); - Navigator.of(context).pop(); - }, - onNo: () { - Navigator.of(context).pop(); - }, - ), + builder: (context) { + final loc = AppLocalizations.of(context)!; + return ConfirmDialog( + title: loc.confirm, + content: loc.msgConfirmResignation, + onYes: () { + widget.game.resign(); + Navigator.of(context).pop(); + }, + onNo: () { + Navigator.of(context).pop(); + }, + ); + }, ); } @@ -491,11 +529,12 @@ class _GamePageState extends State { } void onAutomaticCountingClicked() { + final loc = AppLocalizations.of(context)!; // We can only request automatic counting if we are playing and it's our turn. if (_state != GameState.playing) return; if (_turn != widget.game.myColor) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Please wait for your turn'), + content: Text(loc.msgPleaseWaitForYourTurn), showCloseIcon: true, )); return; @@ -509,9 +548,10 @@ class _GamePageState extends State { barrierDismissible: false, routeSettings: RouteSettings(name: _waitingDialogName), builder: (context) { + final loc = AppLocalizations.of(context)!; return TimedDialog( - title: 'Automatic Counting', - content: "Waiting for your opponent's decision...", + title: loc.autoCounting, + content: loc.msgWaitingForOpponentsDecision, duration: info.timeout, ); }, @@ -537,21 +577,24 @@ class _GamePageState extends State { if (_state == GameState.playing && agree) { showDialog( context: context, - builder: (context) => ConfirmDialog( - title: 'Confirm', - content: 'Your opponent requests automatic counting. Do you agree?', - onYes: () { - widget.game.agreeToAutomaticCounting(true); - setState(() { - _state = GameState.counting; - }); - Navigator.of(context).pop(); - }, - onNo: () { - widget.game.agreeToAutomaticCounting(false); - Navigator.of(context).pop(); - }, - ), + builder: (context) { + final loc = AppLocalizations.of(context)!; + return ConfirmDialog( + title: loc.confirm, + content: loc.msgYourOpponentRequestsAutomaticCounting, + onYes: () { + widget.game.agreeToAutomaticCounting(true); + setState(() { + _state = GameState.counting; + }); + Navigator.of(context).pop(); + }, + onNo: () { + widget.game.agreeToAutomaticCounting(false); + Navigator.of(context).pop(); + }, + ); + }, ); } return; @@ -566,8 +609,9 @@ class _GamePageState extends State { } else { // If opponent refuses, go back into playing state if (context.mounted) { + final loc = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Your opponent refuses to count'), + content: Text(loc.msgYourOpponentRefusesToCount), showCloseIcon: true, )); } @@ -585,8 +629,9 @@ class _GamePageState extends State { if (!accept) { maybeDismissRoute(_countingResultBottomSheetName); if (context.mounted) { + final loc = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Your opponent disagrees with the counting result'), + content: Text(loc.msgYourOpponentDisagreesWithCountingResult), showCloseIcon: true, )); } @@ -652,11 +697,12 @@ class _GamePageState extends State { } void onAIRefereeClicked() { + final loc = AppLocalizations.of(context)!; // We can only request AI referee if we are playing and it's our turn. if (_state != GameState.playing) return; if (_turn != widget.game.myColor) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Please wait for your turn'), + content: Text(loc.msgPleaseWaitForYourTurn), showCloseIcon: true, )); return; @@ -665,7 +711,7 @@ class _GamePageState extends State { (widget.serverFeatures.aiRefereeMinMoveCount[widget.game.boardSize] ?? (1 << 30))) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('AI Referee cannot be used yet'), + content: Text(loc.msgCannotUseAIRefereeYet), showCloseIcon: true, )); return; @@ -682,11 +728,12 @@ class _GamePageState extends State { } void onForceCountingClicked() { + final loc = AppLocalizations.of(context)!; // We can only request forced counting if we are playing and it's our turn. if (_state != GameState.playing) return; if (_turn != widget.game.myColor) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Please wait for your turn'), + content: Text(loc.msgPleaseWaitForYourTurn), showCloseIcon: true, )); return; @@ -696,7 +743,7 @@ class _GamePageState extends State { .forcedCountingMinMoveCount[widget.game.boardSize] ?? (1 << 30))) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Forced counting cannot be used yet'), + content: Text(loc.msgCannotUseForcedCountingYet), showCloseIcon: true, )); return; @@ -723,12 +770,13 @@ class _GamePageState extends State { showDialog( context: context, builder: (context) { + final loc = AppLocalizations.of(context)!; return AlertDialog( - title: const Text('Result'), + title: Text(loc.result), content: Text(res.result), actions: [ TextButton( - child: const Text('OK'), + child: Text(loc.ok), onPressed: () { Navigator.of(context).pop(); }, @@ -828,28 +876,29 @@ class _GameInfoCard extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Card( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Game Info', + loc.gameInfo, textAlign: TextAlign.center, style: TextTheme.of(context).titleLarge, ), Text( - 'Rules: ${game.rules.toString()}', + '${loc.rules}: ${game.rules.toLocalizedString(loc)}', textAlign: TextAlign.center, ), if (game.handicap > 1) Text( - 'Handicap: ${game.handicap}', + '${loc.handicap}: ${game.handicap}', textAlign: TextAlign.center, ), if (game.handicap <= 1) Text( - 'Komi: ${game.komi}', + '${loc.komi}: ${game.komi}', textAlign: TextAlign.center, ), ], diff --git a/lib/play/game_record_page.dart b/lib/play/game_record_page.dart index d7fb8bbf..564617b7 100644 --- a/lib/play/game_record_page.dart +++ b/lib/play/game_record_page.dart @@ -5,13 +5,23 @@ import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/coordinate_style.dart'; import 'package:wqhub/game_client/game_client.dart'; import 'package:wqhub/game_client/game_record.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/play/game_navigation_bar.dart'; import 'package:wqhub/play/player_card.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/wq/annotated_game_tree.dart'; import 'package:wqhub/wq/wq.dart' as wq; +class GameRecordRouteArguments { + final GameSummary summary; + final GameRecord record; + + const GameRecordRouteArguments({required this.summary, required this.record}); +} + class GameRecordPage extends StatefulWidget { + static const routeName = '/play/game_record'; + const GameRecordPage( {super.key, required this.summary, required this.record}); @@ -40,6 +50,7 @@ class _GameRecordPageState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; // Board @@ -165,7 +176,7 @@ class _GameRecordPageState extends State { floatingActionButton: wideLayout ? FloatingActionButton.large( onPressed: onLeaveClicked, - tooltip: 'Leave game', + tooltip: loc.leave, child: Icon(Icons.logout), ) : null, @@ -180,9 +191,7 @@ class _GameRecordPageState extends State { .moveAnnotated((col: _turn!, p: p), mode: AnnotationMode.variation); if (node == null) return; - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } + AudioController().playForNode(_gameTree.curNode); // Toggle current turn setState(() { @@ -202,18 +211,19 @@ class _GameInfoCard extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Card( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Game Record', + loc.gameRecord, textAlign: TextAlign.center, style: TextTheme.of(context).titleLarge, ), Text( - 'Result: ${summary.result.result}', + '${loc.result}: ${summary.result.result}', textAlign: TextAlign.center, ), // if (game.handicap > 1) diff --git a/lib/play/gameplay_bar.dart b/lib/play/gameplay_bar.dart index 6003a7fe..29e5fc17 100644 --- a/lib/play/gameplay_bar.dart +++ b/lib/play/gameplay_bar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wqhub/game_client/server_features.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; class GameplayBar extends StatelessWidget { final ServerFeatures features; @@ -23,6 +24,7 @@ class GameplayBar extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.all(4.0), child: Column( @@ -36,14 +38,14 @@ class GameplayBar extends StatelessWidget { Expanded( child: ElevatedButton.icon( onPressed: onPass, - label: const Text('Pass'), + label: Text(loc.pass), icon: Icon(Icons.fast_forward), ), ), Expanded( child: ElevatedButton.icon( onPressed: onResign, - label: const Text('Resign'), + label: Text(loc.resign), icon: Icon(Icons.flag), ), ), @@ -57,14 +59,14 @@ class GameplayBar extends StatelessWidget { child: ElevatedButton.icon( onPressed: features.automaticCounting ? onAutomaticCounting : null, - label: const Text('Auto Counting'), + label: Text(loc.autoCounting), icon: Icon(Icons.calculate), ), ), Expanded( child: ElevatedButton.icon( onPressed: features.forcedCounting ? onForceCounting : null, - label: const Text('Force Counting'), + label: Text(loc.forceCounting), icon: Icon(Icons.sports), ), ), @@ -77,7 +79,7 @@ class GameplayBar extends StatelessWidget { Expanded( child: ElevatedButton.icon( onPressed: features.aiReferee ? onAIReferee : null, - label: const Text('AI Referee'), + label: Text(loc.aiReferee), icon: Icon(Icons.smart_toy), ), ), diff --git a/lib/play/gameplay_menu.dart b/lib/play/gameplay_menu.dart index 6b750efc..9b92567f 100644 --- a/lib/play/gameplay_menu.dart +++ b/lib/play/gameplay_menu.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wqhub/game_client/rules.dart'; import 'package:wqhub/game_client/server_features.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; class GameplayMenu extends StatelessWidget { final ServerFeatures features; @@ -34,28 +35,29 @@ class GameplayMenu extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return MenuAnchor( menuChildren: [ Padding( padding: const EdgeInsets.all(8.0), - child: Text('Rules: ${rules.toString()}'), + child: Text('${loc.rules}: ${rules.toLocalizedString(loc)}'), ), if (handicap > 1) Padding( padding: const EdgeInsets.all(8.0), - child: Text('Handicap: $handicap'), + child: Text('${loc.handicap}: $handicap'), ), if (handicap < 2) Padding( padding: const EdgeInsets.all(8.0), - child: Text('Komi: $komi'), + child: Text('${loc.komi}: $komi'), ), Divider(thickness: 1, height: 1), if (!isGameOver) MenuItemButton( leadingIcon: Icon(Icons.fast_forward), onPressed: onPass, - child: const Text('Pass'), + child: Text(loc.pass), ), if (!isGameOver && features.manualCounting) MenuItemButton( @@ -67,25 +69,25 @@ class GameplayMenu extends StatelessWidget { MenuItemButton( leadingIcon: Icon(Icons.calculate), onPressed: onAutomaticCounting, - child: const Text('Automatic Counting'), + child: Text(loc.autoCounting), ), if (!isGameOver && features.aiReferee) MenuItemButton( leadingIcon: Icon(Icons.smart_toy), onPressed: onAIReferee, - child: const Text('AI Referee'), + child: Text(loc.aiReferee), ), if (!isGameOver && features.forcedCounting) MenuItemButton( leadingIcon: Icon(Icons.sports), onPressed: onForceCounting, - child: const Text('Force Counting'), + child: Text(loc.forceCounting), ), if (!isGameOver) MenuItemButton( leadingIcon: Icon(Icons.flag), onPressed: onResign, - child: const Text('Resign'), + child: Text(loc.resign), ), if (isGameOver) MenuItemButton( diff --git a/lib/play/login_form.dart b/lib/play/login_form.dart index 355168c0..e6b23e35 100644 --- a/lib/play/login_form.dart +++ b/lib/play/login_form.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; class LoginForm extends StatefulWidget { final String? username; @@ -43,6 +44,7 @@ class _LoginFormState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Form( key: _formKey, child: Column( @@ -55,7 +57,7 @@ class _LoginFormState extends State { maxLength: 32, decoration: InputDecoration( border: OutlineInputBorder(), - labelText: 'Username', + labelText: loc.username, ), validator: (value) { if (value == null || value.isEmpty) { @@ -70,7 +72,7 @@ class _LoginFormState extends State { maxLength: 32, decoration: InputDecoration( border: OutlineInputBorder(), - labelText: 'Password', + labelText: loc.password, ), obscureText: true, validator: (value) { @@ -93,7 +95,7 @@ class _LoginFormState extends State { _passwordController.text); } }, - child: const Text('Login'), + child: Text(loc.login), ), ), if (!Platform.isIOS && @@ -102,7 +104,7 @@ class _LoginFormState extends State { Expanded( child: ElevatedButton( onPressed: () => _launchUrl(widget.registerUrl!), - child: const Text('Register'), + child: Text(loc.register), ), ), ], diff --git a/lib/play/my_games_page.dart b/lib/play/my_games_page.dart index 483cfc04..67556c2d 100644 --- a/lib/play/my_games_page.dart +++ b/lib/play/my_games_page.dart @@ -1,14 +1,16 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:path/path.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:wqhub/file_picker.dart'; import 'package:wqhub/game_client/game_client.dart'; import 'package:wqhub/game_client/game_record.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/play/game_record_page.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/window_class_aware_state.dart'; @@ -19,7 +21,17 @@ import 'package:async/async.dart'; final _dateFormat = DateFormat('yyyy.MM.dd hh.mm'); +class MyGamesRouteArguments { + final GameClient gameClient; + final Future> gameList; + + const MyGamesRouteArguments( + {required this.gameClient, required this.gameList}); +} + class MyGamesPage extends StatefulWidget { + static const routeName = '/play/my_games'; + const MyGamesPage( {super.key, required this.gameClient, required this.gameList}); @@ -33,8 +45,9 @@ class MyGamesPage extends StatefulWidget { class _MyGamesPageState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar(title: const Text('My games')), + appBar: AppBar(title: Text(loc.myGames)), body: FutureBuilder( future: widget.gameList, builder: (context, snapshot) { @@ -67,8 +80,7 @@ class _MyGamesPageState extends State { }, ); } else if (snapshot.hasError) { - return Center( - child: Text('Failed to load game list. Please try again.')); + return Center(child: Text(loc.errFailedToLoadGameList)); } return Center(child: CircularProgressIndicator()); }, @@ -77,20 +89,20 @@ class _MyGamesPageState extends State { } void onTapGame(BuildContext context, GameSummary summary) { + final loc = AppLocalizations.of(context)!; final recordFut = widget.gameClient.getGame(summary.id); _GameLoadingDialog.show( context, - 'Downloading game', + loc.msgDownloadingGame, summary, recordFut, onRecord: (context, summary, record) { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => GameRecordPage( - summary: summary, - record: record, - ), + GameRecordPage.routeName, + arguments: GameRecordRouteArguments( + summary: summary, + record: record, ), ); }, @@ -98,7 +110,7 @@ class _MyGamesPageState extends State { if (context.mounted) { ScaffoldMessenger.of(context).removeCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Failed to download game'), + content: Text(loc.errFailedToDownloadGame), showCloseIcon: true, behavior: SnackBarBehavior.floating, )); @@ -108,63 +120,57 @@ class _MyGamesPageState extends State { } void onDownload(BuildContext context, GameSummary summary) { + final loc = AppLocalizations.of(context)!; final recordFut = widget.gameClient.getGame(summary.id); - _GameLoadingDialog.show(context, 'Downloading game', summary, recordFut, + _GameLoadingDialog.show(context, loc.msgDownloadingGame, summary, recordFut, onRecord: (context, summary, record) async { - final filename = + final fileName = '${_dateFormat.format(summary.dateTime)} - ${summary.white.username} vs ${summary.black.username}.${record.type.name}'; if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { if (context.mounted) { - showDialog( - context: context, - builder: (context) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: FilePicker( - initialDirectory: Directory(context.settings.saveDirectory), - title: 'Select directory', - ), - ); - }, - ).then((Directory? dir) async { - if (dir != null) { - final f = File(join(dir.path, filename)); - await f.writeAsBytes(record.rawData); - if (context.mounted) { - ScaffoldMessenger.of(context).removeCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Game saved to ${dir.path}'), - showCloseIcon: true, - behavior: SnackBarBehavior.floating, - )); - context.settings.saveDirectory = dir.path; - } - } - }); + final savePath = await FilePicker.platform.saveFile( + fileName: fileName, + initialDirectory: context.settings.getSaveDirectory(), + ); + if (savePath != null) { + final f = File(savePath); + await f.writeAsBytes(record.rawData); + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(loc.msgGameSavedTo(savePath)), + showCloseIcon: true, + behavior: SnackBarBehavior.floating, + )); + context.settings.saveDirectory = dirname(savePath); + } } } else { Rect? sharePositionOrigin; - if (Platform.isIOS) { - final box = context.findRenderObject() as RenderBox?; - sharePositionOrigin = box!.localToGlobal(Offset.zero) & box.size; + if (defaultTargetPlatform == TargetPlatform.iOS) { + DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + IosDeviceInfo iosInfo = await deviceInfo.iosInfo; + if (iosInfo.model.toLowerCase().contains('ipad')) { + final box = context.findRenderObject() as RenderBox?; + sharePositionOrigin = box!.localToGlobal(Offset.zero) & box.size; + } } - await Share.shareXFiles( - [ + final params = ShareParams( + files: [ XFile.fromData( Uint8List.fromList(record.rawData), mimeType: 'application/x-go-${record.type.name}', ) ], - text: filename, - fileNameOverrides: [filename], + fileNameOverrides: [fileName], sharePositionOrigin: sharePositionOrigin, ); + await SharePlus.instance.share(params); } }, onError: (err) { if (context.mounted) { ScaffoldMessenger.of(context).removeCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Failed to download game'), + content: Text(loc.errFailedToDownloadGame), showCloseIcon: true, behavior: SnackBarBehavior.floating, )); @@ -173,10 +179,11 @@ class _MyGamesPageState extends State { } void onAISensei(BuildContext context, GameSummary summary) { + final loc = AppLocalizations.of(context)!; final recordFut = widget.gameClient.getGame(summary.id); _GameLoadingDialog.show( context, - 'Downloading game', + loc.msgDownloadingGame, summary, recordFut, onRecord: (context, summary, record) async { @@ -193,7 +200,7 @@ class _MyGamesPageState extends State { if (context.mounted) { ScaffoldMessenger.of(context).removeCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Failed to upload game to AI Sensei'), + content: Text(loc.errFailedToUploadGameToAISensei), showCloseIcon: true, behavior: SnackBarBehavior.floating, )); @@ -224,6 +231,7 @@ class _GameListTile extends StatefulWidget { class _GameListTileState extends WindowClassAwareState<_GameListTile> { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; if (isWindowClassCompact) { return Slidable( key: ValueKey(widget.summary.id), @@ -233,14 +241,14 @@ class _GameListTileState extends WindowClassAwareState<_GameListTile> { SlidableAction( backgroundColor: Colors.green, icon: Icons.download, - label: 'Download', + label: loc.download, padding: EdgeInsets.all(8), onPressed: (context) => widget.onDownload(), ), SlidableAction( backgroundColor: Colors.blue, icon: Icons.smart_toy, - label: 'AI Sensei', + label: loc.aiSensei, padding: EdgeInsets.all(8), onPressed: (context) => widget.onAISensei(), ), @@ -286,12 +294,12 @@ class _GameListTileState extends WindowClassAwareState<_GameListTile> { children: [ IconButton( icon: const Icon(Icons.download), - tooltip: 'Download game', + tooltip: loc.tooltipDownloadGame, onPressed: widget.onDownload, ), IconButton( icon: const Icon(Icons.smart_toy), - tooltip: 'Analyze with AI Sensei', + tooltip: loc.tooltipAnalyzeWithAISensei, onPressed: widget.onAISensei, ), ], @@ -311,6 +319,7 @@ class _GameLoadingDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return AlertDialog( title: Text(title), content: Column( @@ -323,7 +332,7 @@ class _GameLoadingDialog extends StatelessWidget { actions: [ TextButton( onPressed: () => record.cancel(), - child: const Text('Cancel'), + child: Text(loc.cancel), ), ], ); diff --git a/lib/play/player_card.dart b/lib/play/player_card.dart index 68791ba3..73fdcd80 100644 --- a/lib/play/player_card.dart +++ b/lib/play/player_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wqhub/blinking_icon.dart'; import 'package:wqhub/game_client/user_info.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/turn_icon.dart'; import 'package:wqhub/wq/wq.dart' as wq; @@ -24,6 +25,7 @@ class PlayerCard extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Card( clipBehavior: Clip.hardEdge, child: InkWell( @@ -42,7 +44,7 @@ class PlayerCard extends StatelessWidget { overflow: TextOverflow.fade, softWrap: false, ), - Text('Captures: $captureCount', + Text('${loc.captures}: $captureCount', style: TextTheme.of(context).labelSmall), ], ), diff --git a/lib/play/server_card.dart b/lib/play/server_card.dart index 3511e65d..031106f8 100644 --- a/lib/play/server_card.dart +++ b/lib/play/server_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wqhub/game_client/game_client.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/play/server_lobby_page.dart'; import 'package:wqhub/play/server_login_page.dart'; @@ -10,6 +11,7 @@ class ServerCard extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Card( child: InkWell( onTap: () => _onTap(context), @@ -18,8 +20,8 @@ class ServerCard extends StatelessWidget { ListTile( leading: Icon(Icons.videogame_asset), title: Text( - '${gameClient.serverInfo.name} (${gameClient.serverInfo.nativeName})'), - subtitle: Text(gameClient.serverInfo.description), + '${gameClient.serverInfo.name(loc)} (${gameClient.serverInfo.nativeName})'), + subtitle: Text(gameClient.serverInfo.description(loc)), ), ], ), @@ -28,15 +30,18 @@ class ServerCard extends StatelessWidget { } void _onTap(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return gameClient.userInfo.value == null - ? ServerLoginPage(gameClient: gameClient) - : ServerLobbyPage(gameClient: gameClient); - }, - ), - ); + if (gameClient.userInfo.value == null) { + Navigator.pushNamed( + context, + ServerLoginPage.routeName, + arguments: ServerLoginRouteArguments(gameClient: gameClient), + ); + } else { + Navigator.pushNamed( + context, + ServerLobbyPage.routeName, + arguments: ServerLobbyRouteArguments(gameClient: gameClient), + ); + } } } diff --git a/lib/play/server_lobby_page.dart b/lib/play/server_lobby_page.dart index 5a494006..6482bded 100644 --- a/lib/play/server_lobby_page.dart +++ b/lib/play/server_lobby_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wqhub/audio/audio_controller.dart'; import 'package:wqhub/game_client/user_info.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/play/automatch_page.dart'; import 'package:wqhub/game_client/game_client.dart'; import 'package:wqhub/play/game_page.dart'; @@ -8,10 +9,17 @@ import 'package:wqhub/play/my_games_page.dart'; import 'package:wqhub/play/promotion_card.dart'; import 'package:wqhub/play/streak_card.dart'; import 'package:wqhub/play/user_info_card.dart'; -import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; -import 'package:wqhub/window_class_aware_state.dart'; +import 'package:wqhub/pop_and_window_class_aware_state.dart'; + +class ServerLobbyRouteArguments { + final GameClient gameClient; + + const ServerLobbyRouteArguments({required this.gameClient}); +} class ServerLobbyPage extends StatefulWidget { + static const routeName = '/play/lobby'; + const ServerLobbyPage({super.key, required this.gameClient}); final GameClient gameClient; @@ -20,24 +28,22 @@ class ServerLobbyPage extends StatefulWidget { State createState() => _ServerLobbyPageState(); } -class _ServerLobbyPageState extends WindowClassAwareState { +class _ServerLobbyPageState + extends PopAndWindowClassAwareState { @override void initState() { super.initState(); widget.gameClient.disconnected.addListener(onDisconnected); widget.gameClient.ongoingGame().then((game) { if (context.mounted && game != null) { - if (context.settings.sound) AudioController().startToPlay(); - Navigator.push( + AudioController().startToPlay(); + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: GamePage( - serverFeatures: widget.gameClient.serverFeatures, - game: game, - ), - ), + GamePage.routeName, + arguments: GameRouteArguments( + serverFeatures: widget.gameClient.serverFeatures, + game: game, + gameListener: null, ), ); } @@ -52,6 +58,7 @@ class _ServerLobbyPageState extends WindowClassAwareState { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final userInfoCard = ValueListenableBuilder( valueListenable: widget.gameClient.userInfo, builder: (context, info, child) { @@ -82,10 +89,11 @@ class _ServerLobbyPageState extends WindowClassAwareState { children: [ for (final preset in widget.gameClient.automatchPresets) ListTile( - leading: Text('${preset.boardSize}x${preset.boardSize}'), + leading: Text(loc.nxnBoardSize(preset.boardSize)), title: Text( - '${preset.timeControl.mainTime.inMinutes}m ${preset.timeControl.periodCount}x${preset.timeControl.timePerPeriod.inSeconds}s'), - subtitle: Text('Rules: ${preset.rules.toString()}'), + '${loc.mMinutes(preset.timeControl.mainTime.inMinutes)} ${loc.pxsByoyomi(preset.timeControl.periodCount, preset.timeControl.timePerPeriod.inSeconds)}'), + subtitle: + Text('${loc.rules}: ${preset.rules.toLocalizedString(loc)}'), trailing: (preset.playerCount != null) ? Row( mainAxisSize: MainAxisSize.min, @@ -97,14 +105,12 @@ class _ServerLobbyPageState extends WindowClassAwareState { ) : null, onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: AutomatchPage( - gameClient: widget.gameClient, preset: preset), - ), + AutomatchPage.routeName, + arguments: AutomatchRouteArguments( + gameClient: widget.gameClient, + preset: preset, ), ); }, @@ -114,7 +120,7 @@ class _ServerLobbyPageState extends WindowClassAwareState { return Scaffold( appBar: AppBar( - title: Text(widget.gameClient.serverInfo.name), + title: Text(widget.gameClient.serverInfo.name(loc)), actions: [ TextButton.icon( onPressed: () { @@ -122,7 +128,7 @@ class _ServerLobbyPageState extends WindowClassAwareState { Navigator.popUntil(context, (route) => route.isFirst); }, icon: const Icon(Icons.logout), - label: const Text('Logout'), + label: Text(loc.logout), ), ], ), @@ -133,7 +139,7 @@ class _ServerLobbyPageState extends WindowClassAwareState { streakCard, promotionRequirementCard, Divider(), - const Text('Auto-Match'), + Text(loc.autoMatch), Expanded(child: automatchPresetList), ], ) @@ -152,11 +158,11 @@ class _ServerLobbyPageState extends WindowClassAwareState { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, children: [ - const Text('User info'), + Text(loc.userInfo), userInfoCard, - const Text('Recent record'), + Text(loc.recentRecord), streakCard, - const Text('Promotion requirements'), + Text(loc.promotionRequirements), promotionRequirementCard, ], ), @@ -166,7 +172,7 @@ class _ServerLobbyPageState extends WindowClassAwareState { child: Column( children: [ Text( - 'Auto-Match', + loc.autoMatch, style: TextTheme.of(context).titleLarge, ), Expanded(child: automatchPresetList), @@ -176,15 +182,14 @@ class _ServerLobbyPageState extends WindowClassAwareState { ], ), floatingActionButton: FloatingActionButton.extended( - label: const Text('My games'), + label: Text(loc.myGames), onPressed: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => MyGamesPage( - gameClient: widget.gameClient, - gameList: widget.gameClient.listGames(), - ), + MyGamesPage.routeName, + arguments: MyGamesRouteArguments( + gameClient: widget.gameClient, + gameList: widget.gameClient.listGames(), ), ); }, diff --git a/lib/play/server_login_page.dart b/lib/play/server_login_page.dart index 39f486db..236def19 100644 --- a/lib/play/server_login_page.dart +++ b/lib/play/server_login_page.dart @@ -1,10 +1,19 @@ import 'package:flutter/material.dart'; import 'package:wqhub/game_client/game_client.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/play/login_form.dart'; import 'package:wqhub/play/server_lobby_page.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; +class ServerLoginRouteArguments { + final GameClient gameClient; + + const ServerLoginRouteArguments({required this.gameClient}); +} + class ServerLoginPage extends StatefulWidget { + static const routeName = '/play/login'; + const ServerLoginPage({super.key, required this.gameClient}); final GameClient gameClient; @@ -19,9 +28,10 @@ class _ServerLoginPageState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( - title: Text(widget.gameClient.serverInfo.name), + title: Text(widget.gameClient.serverInfo.name(loc)), ), body: FutureBuilder( future: _serverReady, @@ -66,20 +76,19 @@ class _ServerLoginPageState extends State { if (context.mounted) { context.settings.setUsername(widget.gameClient.serverInfo.id, username); context.settings.setPassword(widget.gameClient.serverInfo.id, password); - Navigator.pushReplacement( + Navigator.pushReplacementNamed( context, - MaterialPageRoute( - builder: (context) => - ServerLobbyPage(gameClient: widget.gameClient), - ), + ServerLobbyPage.routeName, + arguments: ServerLobbyRouteArguments(gameClient: widget.gameClient), ); } }, onError: (err) { if (context.mounted) { + final loc = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( SnackBar( behavior: SnackBarBehavior.floating, - content: Text('Invalid username or password'), + content: Text(loc.errIncorrectUsernameOrPassword), showCloseIcon: true, ), ); diff --git a/lib/pop_and_window_class_aware_state.dart b/lib/pop_and_window_class_aware_state.dart new file mode 100644 index 00000000..4879435c --- /dev/null +++ b/lib/pop_and_window_class_aware_state.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/routes.dart'; +import 'package:wqhub/window_class_aware_state.dart'; + +abstract class PopAndWindowClassAwareState + extends WindowClassAwareState with RouteAware { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + routeObserver.subscribe(this, ModalRoute.of(context)!); + } + + @override + void dispose() { + routeObserver.unsubscribe(this); + super.dispose(); + } + + @override + void didPopNext() { + super.didPopNext(); + setState(() { + // Force rebuild to update the widget when returning to this page + }); + } +} diff --git a/lib/pop_aware_state.dart b/lib/pop_aware_state.dart new file mode 100644 index 00000000..85b98655 --- /dev/null +++ b/lib/pop_aware_state.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/routes.dart'; + +abstract class PopAwareState extends State + with RouteAware { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + routeObserver.subscribe(this, ModalRoute.of(context)!); + } + + @override + void dispose() { + routeObserver.unsubscribe(this); + super.dispose(); + } + + @override + void didPopNext() { + super.didPopNext(); + setState(() { + // Force rebuild to update the widget when returning to this page + }); + } +} diff --git a/lib/random_util.dart b/lib/random_util.dart index ae8f5da5..a53b6483 100644 --- a/lib/random_util.dart +++ b/lib/random_util.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; T randomDist(Iterable<(T, int)> dist) { final sum = dist.sumBy((e) => e.$2); @@ -12,3 +13,14 @@ T randomDist(Iterable<(T, int)> dist) { } throw UnimplementedError('cannot happen'); } + +T randomDistFloat(Iterable<(T, double)> dist) { + final sum = dist.sumBy((e) => e.$2); + final x = clampDouble(sum * Random().nextDouble(), 0, sum); + var cur = 0.0; + for (final (t, p) in dist) { + if (x < cur + p) return t; + cur += p; + } + throw UnimplementedError('cannot happen'); +} diff --git a/lib/routes.dart b/lib/routes.dart new file mode 100644 index 00000000..f7811504 --- /dev/null +++ b/lib/routes.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/local_board_page.dart'; +import 'package:wqhub/main_page.dart'; +import 'package:wqhub/play/automatch_page.dart'; +import 'package:wqhub/play/game_page.dart'; +import 'package:wqhub/play/game_record_page.dart'; +import 'package:wqhub/play/my_games_page.dart'; +import 'package:wqhub/play/server_lobby_page.dart'; +import 'package:wqhub/play/server_login_page.dart'; +import 'package:wqhub/settings/about_page.dart'; +import 'package:wqhub/settings/appearance_settings_page.dart'; +import 'package:wqhub/settings/behavior_settings_page.dart'; +import 'package:wqhub/settings/language_page.dart'; +import 'package:wqhub/settings/settings_page.dart'; +import 'package:wqhub/settings/settings_route_arguments.dart'; +import 'package:wqhub/settings/sound_settings_page.dart'; +import 'package:wqhub/train/collection_page.dart'; +import 'package:wqhub/train/collections_page.dart'; +import 'package:wqhub/train/custom_exam_page.dart'; +import 'package:wqhub/train/custom_exam_selection_page.dart'; +import 'package:wqhub/train/endgame_exam_page.dart'; +import 'package:wqhub/train/endgame_exam_selection_page.dart'; +import 'package:wqhub/train/grading_exam_page.dart'; +import 'package:wqhub/train/grading_exam_selection_page.dart'; +import 'package:wqhub/train/my_mistakes_page.dart'; +import 'package:wqhub/train/ranked_mode_page.dart'; +import 'package:wqhub/train/single_task_page.dart'; +import 'package:wqhub/train/subtag_rank_selection_page.dart'; +import 'package:wqhub/train/subtags_page.dart'; +import 'package:wqhub/train/tag_exam_page.dart'; +import 'package:wqhub/train/tags_page.dart'; +import 'package:wqhub/train/time_frenzy_page.dart'; +import 'package:wqhub/train/train_stats_page.dart'; + +final RouteObserver> routeObserver = + RouteObserver>(); + +final Map routes = { + // Settings + AboutPage.routeName: (context) => AboutPage(), + BehaviourSettingsPage.routeName: (context) => BehaviourSettingsPage(), + SoundSettingsPage.routeName: (context) => SoundSettingsPage(), + // Local board + LocalBoardPage.routeName: (context) => LocalBoardPage(), + // Play + // Train + GradingExamSelectionPage.routeName: (context) => GradingExamSelectionPage(), + EndgameExamSelectionPage.routeName: (context) => EndgameExamSelectionPage(), + CollectionsPage.routeName: (context) => CollectionsPage(), + TagsPage.routeName: (context) => TagsPage(), + CustomExamSelectionPage.routeName: (context) => CustomExamSelectionPage(), + MyMistakesPage.routeName: (context) => MyMistakesPage(), + TrainStatsPage.routeName: (context) => TrainStatsPage(), +}; + +Route? onGenerateRoute(RouteSettings settings) { + switch (settings.name) { + case Navigator.defaultRouteName: + final args = settings.arguments as MainRouteArguments; + return _mpr( + settings, + MainPage( + destination: args.destination, + rebuildApp: args.rebuildApp, + )); + case SettingsPage.routeName: + final args = settings.arguments as SettingsRouteArguments; + return _mpr(settings, SettingsPage(rebuildApp: args.rebuildApp)); + case AppearanceSettingsPage.routeName: + final args = settings.arguments as SettingsRouteArguments; + return _mpr( + settings, AppearanceSettingsPage(rebuildApp: args.rebuildApp)); + case LanguagePage.routeName: + final args = settings.arguments as SettingsRouteArguments; + return _mpr(settings, LanguagePage(rebuildApp: args.rebuildApp)); + case SingleTaskPage.routeName: + final args = settings.arguments as SingleTaskRouteArguments; + return _mpr(settings, SingleTaskPage(task: args.task)); + case TimeFrenzyPage.routeName: + final args = settings.arguments as TimeFrenzyRouteArguments; + return _mprNoPop(settings, TimeFrenzyPage(taskSource: args.taskSource)); + case GradingExamPage.routeName: + final args = settings.arguments as GradingExamRouteArguments; + return _mprNoPop(settings, GradingExamPage(rank: args.rank)); + case EndgameExamPage.routeName: + final args = settings.arguments as EndgameExamRouteArguments; + return _mprNoPop(settings, EndgameExamPage(rank: args.rank)); + case SubtagsPage.routeName: + final args = settings.arguments as SubtagsRouteArguments; + return _mpr(settings, SubtagsPage(tag: args.tag)); + case SubtagRankSelectionPage.routeName: + final args = settings.arguments as SubtagRankSelectionRouteArguments; + return _mpr(settings, SubtagRankSelectionPage(subtag: args.subtag)); + case TagExamPage.routeName: + final args = settings.arguments as TagExamRouteArguments; + return _mprNoPop( + settings, + TagExamPage( + tag: args.tag, + rankRange: args.rankRange, + )); + case CollectionPage.routeName: + final args = settings.arguments as CollectionRouteArguments; + return _mprNoPop( + settings, + CollectionPage( + taskCollection: args.taskCollection, + taskSource: args.taskSource, + initialTask: args.initialTask, + )); + case CustomExamPage.routeName: + final args = settings.arguments as CustomExamRouteArguments; + return _mprNoPop( + settings, + CustomExamPage( + taskCount: args.taskCount, + timePerTask: args.timePerTask, + rankRange: args.rankRange, + maxMistakes: args.maxMistakes, + taskSourceType: args.taskSourceType, + taskTypes: args.taskTypes, + taskTag: args.taskTag, + collectStats: args.collectStats, + )); + case RankedModePage.routeName: + final args = settings.arguments as RankedModeRouteArguments; + return _mprNoPop(settings, RankedModePage(taskSource: args.taskSource)); + case ServerLoginPage.routeName: + final args = settings.arguments as ServerLoginRouteArguments; + return _mpr(settings, ServerLoginPage(gameClient: args.gameClient)); + case ServerLobbyPage.routeName: + final args = settings.arguments as ServerLobbyRouteArguments; + return _mpr(settings, ServerLobbyPage(gameClient: args.gameClient)); + case MyGamesPage.routeName: + final args = settings.arguments as MyGamesRouteArguments; + return _mpr( + settings, + MyGamesPage( + gameClient: args.gameClient, + gameList: args.gameList, + )); + case GameRecordPage.routeName: + final args = settings.arguments as GameRecordRouteArguments; + return _mpr( + settings, + GameRecordPage( + summary: args.summary, + record: args.record, + )); + case AutomatchPage.routeName: + final args = settings.arguments as AutomatchRouteArguments; + return _mprNoPop( + settings, + AutomatchPage( + gameClient: args.gameClient, + preset: args.preset, + )); + case GamePage.routeName: + final args = settings.arguments as GameRouteArguments; + return _mprNoPop( + settings, + GamePage( + serverFeatures: args.serverFeatures, + game: args.game, + gameListener: args.gameListener, + )); + } + assert(false, 'Missing named route implementation: ${settings.name}'); + return null; +} + +Route _mpr(RouteSettings settings, Widget w) => MaterialPageRoute( + settings: settings, + builder: (context) => w, + ); + +Route _mprNoPop(RouteSettings settings, Widget w) => MaterialPageRoute( + settings: settings, + builder: (context) => PopScope( + canPop: false, + child: w, + ), + ); diff --git a/lib/save_sgf_form.dart b/lib/save_sgf_form.dart index fb57e7d7..5776b8ab 100644 --- a/lib/save_sgf_form.dart +++ b/lib/save_sgf_form.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/wq/rank.dart'; class SaveSgfFormResult { @@ -22,7 +23,7 @@ class SaveSgfFormResult { String suggestedFilename() { final buf = - StringBuffer(DateFormat('yyyy.MM.dd hh:mm').format(DateTime.now())); + StringBuffer(DateFormat('yyyy.MM.dd hh.mm').format(DateTime.now())); if (blackNick != null && whiteNick != null) { buf.write(' $blackNick vs $whiteNick'); } @@ -65,6 +66,7 @@ class _SaveSgfFormState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Container( margin: EdgeInsets.all(8), child: Form( @@ -78,7 +80,7 @@ class _SaveSgfFormState extends State { controller: _blackNickController, textAlign: TextAlign.center, decoration: InputDecoration( - hintText: 'Black player', + hintText: loc.black, ), ), ), @@ -89,7 +91,7 @@ class _SaveSgfFormState extends State { textAlign: TextAlign.center, controller: _blackRankController, requestFocusOnTap: true, - label: const Text('Rank'), + label: Text(loc.rank), onSelected: (rank) { setState(() { blackRank = rank; @@ -112,7 +114,7 @@ class _SaveSgfFormState extends State { controller: _whiteNickController, textAlign: TextAlign.center, decoration: InputDecoration( - hintText: 'White player', + hintText: loc.white, ), ), ), @@ -123,7 +125,7 @@ class _SaveSgfFormState extends State { textAlign: TextAlign.center, controller: _whiteRankController, requestFocusOnTap: true, - label: const Text('Rank'), + label: Text(loc.rank), onSelected: (rank) { setState(() { whiteRank = rank; @@ -143,7 +145,7 @@ class _SaveSgfFormState extends State { controller: _rulesController, textAlign: TextAlign.center, decoration: InputDecoration( - hintText: 'Rules (e.g. chinese)', + hintText: '${loc.rules} (e.g. chinese)', ), ), TextFormField( @@ -158,14 +160,14 @@ class _SaveSgfFormState extends State { return null; }, decoration: InputDecoration( - hintText: 'Komi (e.g. 6.5)', + hintText: '${loc.komi} (e.g. 6.5)', ), ), TextFormField( controller: _resultController, textAlign: TextAlign.center, decoration: InputDecoration( - hintText: 'Game result (e.g. B+R)', + hintText: '${loc.result} (e.g. B+R)', ), ), SizedBox(height: 8), @@ -185,7 +187,7 @@ class _SaveSgfFormState extends State { )); } }, - child: const Text('Save'), + child: Text(loc.save), ), ], ), diff --git a/lib/section_button.dart b/lib/section_button.dart new file mode 100644 index 00000000..bf5bfa56 --- /dev/null +++ b/lib/section_button.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class SectionButton extends StatelessWidget { + final IconData icon; + final String label; + final Function() onPressed; + + const SectionButton( + {super.key, + required this.icon, + required this.label, + required this.onPressed}); + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + heroTag: null, + onPressed: onPressed, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8.0, + children: [ + Flexible( + child: Icon(icon), + flex: 1, + ), + Flexible( + child: Text( + label, + textAlign: TextAlign.center, + ), + flex: 3), + ], + ), + ); + } +} diff --git a/lib/settings/about_page.dart b/lib/settings/about_page.dart index abc483ab..5add9c16 100644 --- a/lib/settings/about_page.dart +++ b/lib/settings/about_page.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; class AboutPage extends StatelessWidget { + static const routeName = '/settings/about'; + const AboutPage({super.key}); @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( - title: const Text('About'), + title: Text(loc.about), ), body: Center( child: Column( diff --git a/lib/settings/appearance_settings_list.dart b/lib/settings/appearance_settings_list.dart index f1c8f1b2..0b295793 100644 --- a/lib/settings/appearance_settings_list.dart +++ b/lib/settings/appearance_settings_list.dart @@ -1,8 +1,17 @@ import 'package:flutter/material.dart'; import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/board_theme.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; +extension on ThemeMode { + String toLocalizedString(AppLocalizations loc) => switch (this) { + ThemeMode.system => loc.system, + ThemeMode.light => loc.light, + ThemeMode.dark => loc.dark, + }; +} + class AppearanceSettingsList extends StatelessWidget { final Function() onChanged; final Function() onAppThemeChanged; @@ -12,10 +21,11 @@ class AppearanceSettingsList extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return ListView( children: [ ListTile( - title: const Text('Theme'), + title: Text(loc.theme), trailing: DropdownButton( value: context.settings.themeMode, items: ThemeMode.values.map((mode) { @@ -23,7 +33,7 @@ class AppearanceSettingsList extends StatelessWidget { value: mode, child: Padding( padding: const EdgeInsets.all(8.0), - child: Text(mode.name), + child: Text(mode.toLocalizedString(loc)), ), ); }).toList(), @@ -35,7 +45,7 @@ class AppearanceSettingsList extends StatelessWidget { ), ), ListTile( - title: const Text('Board theme'), + title: Text(loc.boardTheme), trailing: DropdownButton( value: context.settings.boardTheme, borderRadius: BorderRadius.circular(8), @@ -44,7 +54,7 @@ class AppearanceSettingsList extends StatelessWidget { value: theme, child: Padding( padding: const EdgeInsets.all(8.0), - child: Text(theme.id), + child: Text(theme.displayName), ), ); }).toList(), @@ -55,7 +65,7 @@ class AppearanceSettingsList extends StatelessWidget { ), ), ListTile( - title: const Text('Show coordinates'), + title: Text(loc.showCoordinates), trailing: Switch( value: context.settings.showCoordinates, onChanged: (value) { @@ -65,7 +75,7 @@ class AppearanceSettingsList extends StatelessWidget { ), ), ListTile( - title: const Text('Stone shadows'), + title: Text(loc.stoneShadows), trailing: Switch( value: context.settings.stoneShadows, onChanged: (value) { @@ -75,17 +85,17 @@ class AppearanceSettingsList extends StatelessWidget { ), ), ListTile( - title: const Text('Edge line'), + title: Text(loc.edgeLine), trailing: SegmentedButton( selected: {context.settings.edgeLine}, segments: [ ButtonSegment( value: BoardEdgeLine.single, - label: Text('Simple'), + label: Text(loc.simple), ), ButtonSegment( value: BoardEdgeLine.thick, - label: Text('Thick'), + label: Text(loc.thick), ), ], onSelectionChanged: (value) { diff --git a/lib/settings/appearance_settings_page.dart b/lib/settings/appearance_settings_page.dart index f2096539..c6dfba88 100644 --- a/lib/settings/appearance_settings_page.dart +++ b/lib/settings/appearance_settings_page.dart @@ -5,15 +5,18 @@ import 'package:wqhub/board/board.dart'; import 'package:wqhub/board/board_annotation.dart'; import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/coordinate_style.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/appearance_settings_list.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/window_class_aware_state.dart'; import 'package:wqhub/wq/wq.dart' as wq; class AppearanceSettingsPage extends StatefulWidget { - final Function() reloadAppTheme; + static const routeName = '/settings/appearance'; - const AppearanceSettingsPage({super.key, required this.reloadAppTheme}); + final Function() rebuildApp; + + const AppearanceSettingsPage({super.key, required this.rebuildApp}); @override State createState() => _AppearanceSettingsPageState(); @@ -23,6 +26,7 @@ class _AppearanceSettingsPageState extends WindowClassAwareState { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final borderSize = 1.5 * (Theme.of(context).textTheme.labelMedium?.fontSize ?? 0); final border = context.settings.showCoordinates @@ -81,7 +85,7 @@ class _AppearanceSettingsPageState return Scaffold( appBar: AppBar( - title: const Text('Appearance'), + title: Text(loc.appearance), ), body: isWindowClassCompact ? Column( @@ -95,7 +99,7 @@ class _AppearanceSettingsPageState onChanged: () { setState(() {}); }, - onAppThemeChanged: widget.reloadAppTheme, + onAppThemeChanged: widget.rebuildApp, ), ), ], @@ -115,7 +119,7 @@ class _AppearanceSettingsPageState onChanged: () { setState(() {}); }, - onAppThemeChanged: widget.reloadAppTheme, + onAppThemeChanged: widget.rebuildApp, ), ), ], diff --git a/lib/settings/behavior_settings_page.dart b/lib/settings/behavior_settings_page.dart index e24c1169..865571e2 100644 --- a/lib/settings/behavior_settings_page.dart +++ b/lib/settings/behavior_settings_page.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/board/board_sizes.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/train/response_delay.dart'; class BehaviourSettingsPage extends StatefulWidget { + static const routeName = '/settings/behaviour'; + const BehaviourSettingsPage({super.key}); @override @@ -12,14 +16,14 @@ class BehaviourSettingsPage extends StatefulWidget { class _BehaviourSettingsPageState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar(title: const Text('Behaviour')), + appBar: AppBar(title: Text(loc.behaviour)), body: ListView( children: [ ListTile( - title: const Text('Confirm moves'), - subtitle: const Text( - 'Double-tap to confirm moves on large boards to avoid misclicks'), + title: Text(loc.confirmMoves), + subtitle: Text(loc.confirmMovesDesc), trailing: Switch( value: context.settings.confirmMoves, onChanged: (value) { @@ -28,10 +32,32 @@ class _BehaviourSettingsPageState extends State { }, ), ), + if (context.settings.confirmMoves) + ListTile( + title: Text(loc.confirmBoardSize), + subtitle: Text(loc.confirmBoardSizeDesc), + trailing: DropdownButton( + value: context.settings.confirmMovesBoardSize, + items: BoardSizes.values.map((boardSize) { + return DropdownMenuItem( + value: boardSize.value, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text('${boardSize.value}×${boardSize.value}'), + ), + ); + }).toList(), + borderRadius: BorderRadius.circular(8), + onChanged: (int? boardSize) { + context.settings.confirmMovesBoardSize = + boardSize ?? BoardSizes.size_9.value; + setState(() {}); + }, + ), + ), ListTile( - title: const Text('Response delay'), - subtitle: const Text( - 'Duration of the delay before the response appears while solving tasks'), + title: Text(loc.responseDelay), + subtitle: Text(loc.responseDelayDesc), trailing: DropdownButton( value: context.settings.responseDelay, items: ResponseDelay.values.map((delay) { @@ -39,7 +65,7 @@ class _BehaviourSettingsPageState extends State { value: delay, child: Padding( padding: const EdgeInsets.all(8.0), - child: Text(delay.name), + child: Text(delay.toLocalizedString(loc)), ), ); }).toList(), @@ -51,9 +77,8 @@ class _BehaviourSettingsPageState extends State { ), ), ListTile( - title: const Text('Always black-to-play'), - subtitle: const Text( - 'Set all tasks as black-to-play to avoid confusion.'), + title: Text(loc.alwaysBlackToPlay), + subtitle: Text(loc.alwaysBlackToPlayDesc), trailing: Switch( value: context.settings.alwaysBlackToPlay, onChanged: (value) { diff --git a/lib/settings/language_page.dart b/lib/settings/language_page.dart new file mode 100644 index 00000000..c981baf3 --- /dev/null +++ b/lib/settings/language_page.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; + +class LanguagePage extends StatefulWidget { + static const routeName = '/settings/language'; + + final Function() rebuildApp; + + const LanguagePage({super.key, required this.rebuildApp}); + + @override + State createState() => _LanguagePageState(); +} + +class _LanguagePageState extends State { + static final Map nativeLanguageName = { + 'en': 'English', + 'es': 'Español', + 'zh': '中文 – 简体', + 'ru': 'Русский', + }; + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(AppLocalizations.of(context)!.language)), + body: RadioGroup( + groupValue: context.settings.locale, + onChanged: (Locale? loc) { + context.settings.locale = loc!; + widget.rebuildApp(); + }, + child: ListView( + children: [ + for (final loc in AppLocalizations.supportedLocales) + RadioListTile( + title: Text(nativeLanguageName[loc.languageCode]!), + value: loc, + ) + ], + ), + ), + ); + } +} diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index 94b7db5a..66b62041 100644 --- a/lib/settings/settings.dart +++ b/lib/settings/settings.dart @@ -2,8 +2,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wqhub/audio/audio_controller.dart'; import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/board_theme.dart'; +import 'package:wqhub/board/board_sizes.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/train/response_delay.dart'; class Settings { @@ -11,20 +14,27 @@ class Settings { const Settings(this.prefs); - static const defaultSaveDirKey = 'default.save_dir'; + static const _versionPatch = 'internal.version_patch'; static const _themeKey = 'settings.theme'; static const _behaviourKeyPrefix = 'settings.behaviour'; - static const _soundKey = 'settings.sound'; + static const _soundStoneKey = 'settings.sound.stone'; + static const _soundVoiceKey = 'settings.sound.voice'; + static const _soundUIKey = 'settings.sound.ui'; static const _credentialsPrefix = 'settings.auth'; static const _boardTheme = 'settings.board.theme'; static const _boardShowCoordinatesKey = 'settings.board.show_coordinates'; static const _boardStoneShadowsKey = 'settings.board.stone_shadows'; static const _boardEdgeLine = 'settings.board.edge_line'; static const _saveDirectory = 'settings.save_dir'; + static const _locale = 'settings.language.locale'; + static const _helpDialogPrefix = 'settings.help'; // Internal preferences - String get saveDirectory => - prefs.getString(_saveDirectory) ?? prefs.getString(defaultSaveDirKey)!; + bool getVersionPatchStatus(String version) => + prefs.getBool('$_versionPatch.$version.status') ?? false; + void setVersionPatchStatus(String version, bool status) => + prefs.setBool('$_versionPatch.$version.status', status); + String? getSaveDirectory() => prefs.getString(_saveDirectory); set saveDirectory(String dir) => prefs.setString(_saveDirectory, dir); // General @@ -54,6 +64,13 @@ class Settings { set confirmMoves(bool val) => prefs.setBool('$_behaviourKeyPrefix.confirm_moves', val); + int get confirmMovesBoardSize => + prefs.getInt('$_behaviourKeyPrefix.confirm_moves_board_size') ?? + BoardSizes.size_9.value; + + set confirmMovesBoardSize(int boardSize) => + prefs.setInt('$_behaviourKeyPrefix.confirm_moves_board_size', boardSize); + ResponseDelay get responseDelay => ResponseDelay.values[ prefs.getInt('$_behaviourKeyPrefix.response_delay') ?? ResponseDelay.short.index]; @@ -68,8 +85,34 @@ class Settings { prefs.setBool('$_behaviourKeyPrefix.always_black_to_play', val); // Sound - bool get sound => prefs.getBool(_soundKey) ?? true; - set sound(bool val) => prefs.setBool(_soundKey, val); + double get soundStone => prefs.getDouble(_soundStoneKey) ?? 1.0; + set soundStone(double val) => prefs.setDouble(_soundStoneKey, val).then((_) { + AudioController().stoneVolume = val; + }); + + double get soundVoice => prefs.getDouble(_soundVoiceKey) ?? 1.0; + set soundVoice(double val) => prefs.setDouble(_soundVoiceKey, val).then((_) { + AudioController().voiceVolume = val; + }); + + double get soundUI => prefs.getDouble(_soundUIKey) ?? 1.0; + set soundUI(double val) => prefs.setDouble(_soundUIKey, val).then((_) { + AudioController().uiVolume = val; + }); + + // Locale + Locale get locale => Locale(prefs.getString(_locale) ?? _defaultLocale()); + set locale(Locale loc) => prefs.setString(_locale, loc.languageCode); + + String _defaultLocale() { + final platformLoc = Locale(Platform.localeName); + for (final loc in AppLocalizations.supportedLocales) { + if (loc.languageCode == platformLoc.languageCode) { + return loc.languageCode; + } + } + return 'en'; + } // Auth String? getUsername(String serverId) => @@ -85,4 +128,26 @@ class Settings { void setPassword(String serverId, String password) { prefs.setString('$_credentialsPrefix.$serverId.password', password); } + + // Help + bool get showCollectionsHelp => + prefs.getBool('$_helpDialogPrefix.collections') ?? true; + set showCollectionsHelp(bool b) => + prefs.setBool('$_helpDialogPrefix.collections', b); + bool get showGradingExamHelp => + prefs.getBool('$_helpDialogPrefix.grading_exam') ?? true; + set showGradingExamHelp(bool b) => + prefs.setBool('$_helpDialogPrefix.grading_exam', b); + bool get showEndgameExamHelp => + prefs.getBool('$_helpDialogPrefix.endgame_exam') ?? true; + set showEndgameExamHelp(bool b) => + prefs.setBool('$_helpDialogPrefix.endgame_exam', b); + bool get showTimeFrenzyHelp => + prefs.getBool('$_helpDialogPrefix.time_frenzy') ?? true; + set showTimeFrenzyHelp(bool b) => + prefs.setBool('$_helpDialogPrefix.time_frenzy', b); + bool get showRankedModeHelp => + prefs.getBool('$_helpDialogPrefix.ranked_mode') ?? true; + set showRankedModeHelp(bool b) => + prefs.setBool('$_helpDialogPrefix.ranked_mode', b); } diff --git a/lib/settings/settings_button.dart b/lib/settings/settings_button.dart index b8d190d0..f9aaa595 100644 --- a/lib/settings/settings_button.dart +++ b/lib/settings/settings_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wqhub/settings/settings_page.dart'; +import 'package:wqhub/settings/settings_route_arguments.dart'; class SettingsButton extends StatelessWidget { final Function() reloadAppTheme; @@ -10,11 +11,10 @@ class SettingsButton extends StatelessWidget { Widget build(BuildContext context) { return IconButton( onPressed: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => SettingsPage(reloadAppTheme: reloadAppTheme), - ), + SettingsPage.routeName, + arguments: SettingsRouteArguments(rebuildApp: reloadAppTheme), ); }, icon: Icon(Icons.settings), diff --git a/lib/settings/settings_page.dart b/lib/settings/settings_page.dart index 204c1326..916f4c6b 100644 --- a/lib/settings/settings_page.dart +++ b/lib/settings/settings_page.dart @@ -1,13 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/about_page.dart'; import 'package:wqhub/settings/appearance_settings_page.dart'; import 'package:wqhub/settings/behavior_settings_page.dart'; +import 'package:wqhub/settings/language_page.dart'; +import 'package:wqhub/settings/settings_route_arguments.dart'; import 'package:wqhub/settings/sound_settings_page.dart'; class SettingsPage extends StatefulWidget { - final Function() reloadAppTheme; + static const routeName = '/settings'; - const SettingsPage({super.key, required this.reloadAppTheme}); + final Function() rebuildApp; + + const SettingsPage({super.key, required this.rebuildApp}); @override State createState() => _SettingsPageState(); @@ -16,59 +21,63 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar(title: const Text('Settings')), + appBar: AppBar( + title: Text(loc.settings), + ), body: ListView( children: [ ListTile( - title: const Text('Appearance'), + leading: const Icon(Icons.visibility), + title: Text(loc.appearance), trailing: const Icon(Icons.arrow_forward), onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => AppearanceSettingsPage( - reloadAppTheme: widget.reloadAppTheme), - ), + AppearanceSettingsPage.routeName, + arguments: + SettingsRouteArguments(rebuildApp: widget.rebuildApp), ); }, ), ListTile( - title: const Text('Behaviour'), + leading: const Icon(Icons.psychology), + title: Text(loc.behaviour), trailing: const Icon(Icons.arrow_forward), onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BehaviourSettingsPage(), - ), - ); + Navigator.pushNamed(context, BehaviourSettingsPage.routeName); }, ), ListTile( - title: const Text('Sound'), + leading: const Icon(Icons.volume_up), + title: Text(loc.sound), trailing: const Icon(Icons.arrow_forward), onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SoundSettingsPage(), - ), - ); + Navigator.pushNamed(context, SoundSettingsPage.routeName); }, ), ListTile( - title: const Text('About'), + leading: const Icon(Icons.language), + title: Text(loc.language), trailing: const Icon(Icons.arrow_forward), onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => AboutPage(), - ), + LanguagePage.routeName, + arguments: + SettingsRouteArguments(rebuildApp: widget.rebuildApp), ); }, ), + ListTile( + leading: const Icon(Icons.info), + title: Text(loc.about), + trailing: const Icon(Icons.arrow_forward), + onTap: () { + Navigator.pushNamed(context, AboutPage.routeName); + }, + ), ], ), ); diff --git a/lib/settings/settings_route_arguments.dart b/lib/settings/settings_route_arguments.dart new file mode 100644 index 00000000..762ec17a --- /dev/null +++ b/lib/settings/settings_route_arguments.dart @@ -0,0 +1,5 @@ +class SettingsRouteArguments { + final Function() rebuildApp; + + SettingsRouteArguments({required this.rebuildApp}); +} diff --git a/lib/settings/sound_settings_page.dart b/lib/settings/sound_settings_page.dart index da100164..42db283a 100644 --- a/lib/settings/sound_settings_page.dart +++ b/lib/settings/sound_settings_page.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:wqhub/audio/audio_controller.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; class SoundSettingsPage extends StatefulWidget { + static const routeName = '/settings/sound'; + const SoundSettingsPage({super.key}); @override @@ -12,20 +15,61 @@ class SoundSettingsPage extends StatefulWidget { class _SoundSettingsPageState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar(title: const Text('Sound')), + appBar: AppBar(title: Text(loc.sound)), body: ListView( children: [ ListTile( - title: const Text('Sound'), - trailing: Switch( - value: context.settings.sound, + title: Text(loc.stones), + subtitle: Slider( + value: context.settings.soundStone, + label: '${(100 * context.settings.soundStone).floor()}%', + divisions: 10, + onChanged: (value) { + context.settings.soundStone = value; + AudioController().stoneVolume = value; + setState(() {}); + }, + ), + trailing: FilledButton( + onPressed: () => AudioController().playStone(), + child: Text(loc.test), + ), + ), + ListTile( + title: Text(loc.ui), + subtitle: Slider( + value: context.settings.soundUI, + label: '${(100 * context.settings.soundUI).floor()}%', + divisions: 10, onChanged: (value) { - context.settings.sound = value; - if (value) AudioController().correct(); + context.settings.soundUI = value; + AudioController().uiVolume = value; setState(() {}); }, ), + trailing: FilledButton( + onPressed: () => AudioController().correct(), + child: Text(loc.test), + ), + ), + ListTile( + title: Text(loc.voice), + subtitle: Slider( + value: context.settings.soundVoice, + label: '${(100 * context.settings.soundVoice).floor()}%', + divisions: 10, + onChanged: (value) { + context.settings.soundVoice = value; + AudioController().voiceVolume = value; + setState(() {}); + }, + ), + trailing: FilledButton( + onPressed: () => AudioController().startToPlay(), + child: Text(loc.test), + ), ), ], ), diff --git a/lib/stats/stats_db.dart b/lib/stats/stats_db.dart index efdea101..0b5d1d64 100644 --- a/lib/stats/stats_db.dart +++ b/lib/stats/stats_db.dart @@ -1,10 +1,16 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sqlite3/sqlite3.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/train/rank_range.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_tag.dart'; import 'package:wqhub/train/task_type.dart'; import 'package:wqhub/wq/rank.dart'; @@ -40,6 +46,93 @@ class TaskStatEntry { } } +enum ExamType { + grading, + endgame, + custom, + topic, + legacy; +} + +@immutable +class ExamEvent { + final ExamType type; + final TaskTag? tag; + final String? raw; + + const ExamEvent({required this.type, this.tag, this.raw}); + + ExamEvent.fromJson(Map json) + : type = ExamType.values[json['type'] as int], + tag = _maybeAt(TaskTag.values, json['tag']), + raw = null; + + Map toJson() => { + 'type': type.index, + if (tag != null) 'tag': tag!.index, + }; + + @override + int get hashCode => Object.hash(type, tag); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + + return other is ExamEvent && other.type == type && other.tag == tag; + } + + String toLocalizedString(AppLocalizations loc) => switch (type) { + ExamType.grading => loc.gradingExam, + ExamType.endgame => loc.endgameExam, + ExamType.custom => loc.customExam, + ExamType.topic => '${loc.topic} (${tag?.toLocalizedString(loc)})', + ExamType.legacy => raw!, + }; + + static T? _maybeAt(List l, dynamic i) => i == null ? null : l[i as int]; +} + +@immutable +class ExamEntry { + final DateTime date; + final ExamEvent event; + final RankRange rankRange; + final int correctCount; + final int wrongCount; + final bool passed; + final Duration duration; + + const ExamEntry( + {required this.date, + required this.event, + required this.rankRange, + required this.correctCount, + required this.wrongCount, + required this.passed, + required this.duration}); + + @override + int get hashCode => Object.hash( + date, event, rankRange, correctCount, wrongCount, passed, duration); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + + return other is ExamEntry && + other.date == date && + other.event == event && + other.rankRange == rankRange && + other.correctCount == correctCount && + other.wrongCount == wrongCount && + other.passed == passed && + other.duration == duration; + } +} + @immutable class CollectionStatEntry { final int id; @@ -114,16 +207,23 @@ class StatsDB { final Database _db; final PreparedStatement _addTaskAttemptCorrect; + final PreparedStatement _addDailyTaskAttemptCorrect; final PreparedStatement _addTaskAttemptWrong; + final PreparedStatement _addDailyTaskAttemptWrong; final PreparedStatement _ignoreTaskMistake; final PreparedStatement _mistakesByMostRecent; final PreparedStatement _mistakesBySuccessRate; + final PreparedStatement _countMistakesByRange; + final PreparedStatement _mistakesByRankRange; final PreparedStatement _collectionStat; final PreparedStatement _collectionActiveSession; final PreparedStatement _updateCollectionActiveSession; final PreparedStatement _resetCollectionActiveSession; final PreparedStatement _deleteCollectionActiveSession; final PreparedStatement _updateCollectionStat; + final PreparedStatement _addExamAttempt; + final PreparedStatement _examsSince; + final PreparedStatement _taskDailyStatsSince; static init() async { _log.info('init: sqlite3 ${sqlite3.version}'); @@ -160,6 +260,16 @@ class StatsDB { ON task_stats(100 * correct_count / (correct_count + wrong_count), wrong_count DESC) WHERE wrong_count>0 AND NOT ignore_mistake; + CREATE INDEX IF NOT EXISTS mistakes_by_rank + ON task_stats(rank) + WHERE wrong_count>0 AND NOT ignore_mistake; + + CREATE TABLE IF NOT EXISTS task_daily_stats ( + date TEXT PRIMARY KEY, + correct_count INTEGER NOT NULL DEFAULT 0, + wrong_count INTEGER NOT NULL DEFAULT 0 + ); + ---------------------------------------------------------------------------------- -- Collection stats ---------------------------------------------------------------------------------- @@ -177,6 +287,21 @@ class StatsDB { duration_sec INTEGER NOT NULL, completed INTEGER NOT NULL ); + + ---------------------------------------------------------------------------------- + -- Exam history + ---------------------------------------------------------------------------------- + + CREATE TABLE IF NOT EXISTS exams ( + date TEXT PRIMARY KEY, + type TEXT NOT NULL, + from_rank INTEGER NOT NULL, + to_rank INTEGER NOT NULL, + correct_count INTEGER NOT NULL, + wrong_count INTEGER NOT NULL, + passed BOOLEAN NOT NULL, + duration_sec INTEGER NOT NULL + ); '''); _instance = StatsDB._(db: db); @@ -190,6 +315,12 @@ class StatsDB { correct_count = correct_count+1, latest_attempt = CURRENT_TIMESTAMP; ''', persistent: true), + _addDailyTaskAttemptCorrect = db.prepare(''' + INSERT INTO task_daily_stats(date, correct_count, wrong_count) + VALUES(date(), 1, 0) + ON CONFLICT(date) DO UPDATE SET + correct_count = correct_count+1; + ''', persistent: true), _addTaskAttemptWrong = db.prepare(''' INSERT INTO task_stats(rank, type, id, wrong_count) VALUES(?, ?, ?, 1) ON CONFLICT(rank, type, id) DO UPDATE SET @@ -198,6 +329,12 @@ class StatsDB { latest_wrong_attempt = CURRENT_TIMESTAMP, ignore_mistake = 0; ''', persistent: true), + _addDailyTaskAttemptWrong = db.prepare(''' + INSERT INTO task_daily_stats(date, correct_count, wrong_count) + VALUES(date(), 0, 1) + ON CONFLICT(date) DO UPDATE SET + wrong_count = wrong_count+1; + ''', persistent: true), _ignoreTaskMistake = db.prepare(''' UPDATE task_stats SET ignore_mistake = 1 @@ -217,6 +354,16 @@ class StatsDB { wrong_count DESC LIMIT ?; ''', persistent: true), + _countMistakesByRange = db.prepare(''' + SELECT count(1) FROM task_stats + WHERE (rank BETWEEN ? AND ?) AND (wrong_count>0 AND NOT ignore_mistake); + ''', persistent: true), + _mistakesByRankRange = db.prepare(''' + SELECT rank, type, id FROM task_stats + WHERE (rank BETWEEN ? AND ?) AND (wrong_count>0 AND NOT ignore_mistake) + ORDER BY RANDOM() + LIMIT ?; + ''', persistent: true), _collectionStat = db.prepare(''' SELECT id, correct_count, wrong_count, duration_sec, completed FROM collection_stats WHERE id = ?; @@ -255,13 +402,29 @@ class StatsDB { ''', persistent: true), _deleteCollectionActiveSession = db.prepare(''' DELETE FROM collection_active_sessions WHERE id = ?; + ''', persistent: true), + _addExamAttempt = db.prepare(''' + INSERT INTO exams(date, type, from_rank, to_rank, correct_count, wrong_count, passed, duration_sec) + VALUES (datetime(), ?, ?, ?, ?, ?, ?, ?); + ''', persistent: true), + _examsSince = db.prepare(''' + SELECT * FROM exams WHERE date >= ?; + ''', persistent: true), + _taskDailyStatsSince = db.prepare(''' + SELECT + COALESCE(SUM(correct_count), 0) as total_correct_count, + COALESCE(SUM(wrong_count), 0) as total_wrong_count + FROM task_daily_stats + WHERE date >= ?; ''', persistent: true); addTaskAttempt(Rank rank, TaskType type, int id, bool correct) { if (correct) { _addTaskAttemptCorrect.execute([rank.index, type.index, id]); + _addDailyTaskAttemptCorrect.execute(); } else { _addTaskAttemptWrong.execute([rank.index, type.index, id]); + _addDailyTaskAttemptWrong.execute(); } } @@ -279,6 +442,36 @@ class StatsDB { return _entriesFromResultSet(resultSet); } + int countMistakesByRange(RankRange rankRange) { + final result = _countMistakesByRange + .select([rankRange.from.index, rankRange.to.index]); + return result.first.values.first! as int; + } + + List mistakesByRankRange(RankRange rankRange, int n) { + final resultSet = _mistakesByRankRange + .select([rankRange.from.index, rankRange.to.index, n]); + return [ + for (final row in resultSet) + TaskRef( + rank: Rank.values[row['rank'] as int], + type: TaskType.values[row['type'] as int], + id: row['id'] as int, + ) + ]; + } + + void deleteMistakes(Iterable refs) { + final entries = refs + .map((ref) => '(${ref.rank.index},${ref.type.index},${ref.id})') + .join(','); + _db.execute(''' + BEGIN TRANSACTION; + DELETE FROM task_stats WHERE (rank, type, id) IN ($entries); + COMMIT; + '''); + } + List _entriesFromResultSet(ResultSet resultSet) => [ for (final row in resultSet) TaskStatEntry( @@ -341,5 +534,58 @@ class StatsDB { ]); } + addExamAttempt(ExamEvent examType, RankRange rankRange, int correctCount, + int wrongCount, bool passed, Duration duration) { + _addExamAttempt.execute([ + jsonEncode(examType), + rankRange.from.index, + rankRange.to.index, + correctCount, + wrongCount, + passed, + duration.inSeconds, + ]); + } + + List examsSince(DateTime since) { + final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss'); + final resultSet = _examsSince.select([dateFormat.format(since.toUtc())]); + final entries = []; + for (final row in resultSet) { + var typeStr = row['type'] as String; + var event = ExamEvent(type: ExamType.legacy, raw: typeStr); + try { + final type = jsonDecode(typeStr) as Map; + event = ExamEvent.fromJson(type); + } catch (e) {} + entries.add(ExamEntry( + date: dateFormat.parseUTC(row['date'] as String).toLocal(), + event: event, + rankRange: RankRange( + from: Rank.values[row['from_rank'] as int], + to: Rank.values[row['to_rank'] as int], + ), + correctCount: row['correct_count'] as int, + wrongCount: row['wrong_count'] as int, + passed: (row['passed'] as int) != 0, + duration: Duration(seconds: row['duration_sec'] as int), + )); + } + return entries; + } + + (int, int) taskDailyStatsSince(DateTime since) { + final dateFormat = DateFormat('yyyy-MM-dd'); + final resultSet = + _taskDailyStatsSince.select([dateFormat.format(since.toUtc())]); + for (final row in resultSet) { + return ( + row['total_correct_count'] as int, + row['total_wrong_count'] as int, + ); + } + return (0, 0); + } + void dispose() => _db.dispose(); } diff --git a/lib/time_display.dart b/lib/time_display.dart index f0833a78..bec71fa9 100644 --- a/lib/time_display.dart +++ b/lib/time_display.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wqhub/audio/audio_controller.dart'; import 'package:wqhub/game_client/time_state.dart'; -import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; enum TickMode { increase, @@ -133,8 +132,7 @@ class _TimeDisplayState extends State { } void _voiceCountdown() { - if (context.settings.sound && - widget.voiceCountdown && + if (widget.voiceCountdown && widget.timeState.isOvertime && Duration.zero < _timeLeft && _timeLeft <= Duration(seconds: 9)) { diff --git a/lib/train/collection_page.dart b/lib/train/collection_page.dart index 547f579f..8212d07a 100644 --- a/lib/train/collection_page.dart +++ b/lib/train/collection_page.dart @@ -1,21 +1,16 @@ import 'dart:math'; -import 'package:extension_type_unions/extension_type_unions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:wqhub/audio/audio_controller.dart'; -import 'package:wqhub/board/board_annotation.dart'; import 'package:wqhub/game_client/time_state.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/stats/stats_db.dart'; import 'package:wqhub/time_display.dart'; -import 'package:wqhub/train/response_delay.dart'; -import 'package:wqhub/train/solve_status_notifier.dart'; import 'package:wqhub/train/task_action_bar.dart'; import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_solving_state_mixin.dart'; +import 'package:wqhub/train/upsolve_mode.dart'; import 'package:wqhub/turn_icon.dart'; -import 'package:wqhub/wq/annotated_game_tree.dart'; import 'package:wqhub/board/board.dart'; import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/coordinate_style.dart'; @@ -23,7 +18,20 @@ import 'package:wqhub/train/task_source/task_source.dart'; import 'package:wqhub/train/variation_tree.dart'; import 'package:wqhub/wq/wq.dart' as wq; +class CollectionRouteArguments { + final TaskCollection taskCollection; + final TaskSource taskSource; + final int initialTask; + + const CollectionRouteArguments( + {required this.taskCollection, + required this.taskSource, + required this.initialTask}); +} + class CollectionPage extends StatefulWidget { + static const routeName = '/train/collection'; + const CollectionPage( {super.key, required this.taskCollection, @@ -39,23 +47,16 @@ class CollectionPage extends StatefulWidget { } class _CollectionPageState extends State - with SolveStatusNotifier { + with TaskSolvingStateMixin { final _timeDisplayKey = GlobalKey(debugLabel: 'time-display'); final _stopwatch = Stopwatch(); - VariationTreeIterator? _vtreeIt; - var _gameTree = AnnotatedGameTree(19); - var _turn = wq.Color.black; var _taskNumber = 1; - VariationStatus? _solveStatus; - bool _solveStatusNotified = false; - IMapOfSets? _continuationAnnotations; @override void initState() { super.initState(); _taskNumber = widget.initialTask; _stopwatch.start(); - _setupCurrentTask(); } @override @@ -66,6 +67,7 @@ class _CollectionPageState extends State @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; final borderSize = 1.5 * (Theme.of(context).textTheme.labelMedium?.fontSize ?? 0); @@ -102,18 +104,19 @@ class _CollectionPageState extends State return Board( size: boardSize, settings: boardSettings, - onPointClicked: (p) => _onPointClicked(p, wideLayout), - turn: _turn, - stones: _gameTree.stones, - annotations: _continuationAnnotations ?? _gameTree.annotations, + onPointClicked: (p) => onMove(p, wideLayout), + turn: turn, + stones: gameTree.stones, + annotations: continuationAnnotations ?? gameTree.annotations, confirmTap: context.settings.confirmMoves, ); }, ); final taskRank = - _solveStatus != null ? widget.taskSource.task.rank.toString() : '?'; - final taskTitle = '[$taskRank] ${widget.taskSource.task.type.toString()}'; + solveStatus != null ? widget.taskSource.task.rank.toString() : '?'; + final taskTitle = + '[$taskRank] ${widget.taskSource.task.type.toLocalizedString(loc)}'; final timeDisplay = TimeDisplay( key: _timeDisplayKey, @@ -123,7 +126,7 @@ class _CollectionPageState extends State periodCount: 0, ), warningDuration: const Duration(seconds: -1), - enabled: _solveStatus == null, + enabled: solveStatus == null, tickerEnabled: true, tickMode: TickMode.increase, voiceCountdown: false, @@ -141,12 +144,16 @@ class _CollectionPageState extends State taskNumber: _taskNumber, taskCount: widget.taskCollection.taskCount, color: widget.taskSource.task.first, - status: _solveStatus, - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, + status: solveStatus, + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onResetTask: onResetTask, + onNextTask: onNextTask, onExit: _onExit, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, timeDisplay: timeDisplay, ), ], @@ -176,40 +183,64 @@ class _CollectionPageState extends State child: board, ), bottomNavigationBar: BottomAppBar( - child: (_solveStatus == null) + height: upsolveMode == UpsolveMode.auto ? 80.0 : 160.0, + child: (solveStatus == null) ? Center(child: timeDisplay) : TaskActionBar( - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onResetTask: onResetTask, + onNextTask: onNextTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, ), ), ); } } - void _onReplay() { - setState(() { - _setupCurrentTask(); - }); + @override + Task get currentTask => widget.taskSource.task; + + @override + void onSolveStatus(VariationStatus status) { + _stopwatch.stop(); + StatsDB().addTaskAttempt(currentTask.rank, currentTask.type, currentTask.id, + status == VariationStatus.correct); + if (status == VariationStatus.correct) { + context.stats.incrementTotalPassCount(currentTask.rank); + StatsDB().updateCollectionActiveSession( + widget.taskCollection.id, + correctDelta: 1, + durationDelta: _stopwatch.elapsed, + ); + } else { + context.stats.incrementTotalFailCount(currentTask.rank); + StatsDB().updateCollectionActiveSession( + widget.taskCollection.id, + wrongDelta: 1, + durationDelta: _stopwatch.elapsed, + ); + } } _onExit() { - if (!_solveStatusNotified) { + if (!solveStatusNotified) { StatsDB().updateCollectionActiveSession(widget.taskCollection.id, durationDelta: _stopwatch.elapsed); } Navigator.pop(context); } - void _onNext() { + void onNextTask() { if (widget.taskSource - .next(_solveStatus ?? VariationStatus.wrong, _stopwatch.elapsed)) { + .next(solveStatus ?? VariationStatus.wrong, _stopwatch.elapsed)) { _taskNumber++; - _solveStatus = null; + solveStatus = null; setState(() { - _setupCurrentTask(); + setupCurrentTask(); }); _stopwatch.reset(); _stopwatch.start(); @@ -218,126 +249,6 @@ class _CollectionPageState extends State } } - void _setupCurrentTask() { - _continuationAnnotations = null; - _vtreeIt = - VariationTreeIterator(tree: widget.taskSource.task.variationTree); - _gameTree = AnnotatedGameTree(widget.taskSource.task.boardSize); - for (final entry in widget.taskSource.task.initialStones.entries) { - for (final p in entry.value) { - _gameTree - .moveAnnotated((col: entry.key, p: p), mode: AnnotationMode.none); - } - } - _turn = widget.taskSource.task.first; - _solveStatusNotified = false; - } - - void _onPointClicked(wq.Point p, bool wideLayout) { - if (!(_solveStatusNotified || _turn == widget.taskSource.task.first)) { - return; - } - - if (_gameTree.moveAnnotated((col: _turn, p: p), - mode: AnnotationMode.variation) != - null) { - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } - _continuationAnnotations = null; - final status = _vtreeIt!.move(p); - _turn = _turn.opposite; - if (status != null) { - _setSolveStatus(status, wideLayout); - } else { - switch (context.settings.responseDelay) { - case ResponseDelay.none: - _generateResponseMove(wideLayout); - default: - Future.delayed(context.settings.responseDelay.duration, () { - _generateResponseMove(wideLayout); - }); - } - } - setState(() {/* Update board */}); - } - } - - void _generateResponseMove(bool wideLayout) { - if (_solveStatusNotified) return; - - final resp = _vtreeIt!.genMove(); - _gameTree - .moveAnnotated((col: _turn, p: resp), mode: AnnotationMode.variation); - _turn = _turn.opposite; - final status = _vtreeIt!.move(resp); - if (status != null) { - _setSolveStatus(status, wideLayout); - } - setState(() {/* Update board */}); - } - - void _setSolveStatus(VariationStatus status, bool wideLayout) { - setState(() { - _continuationAnnotations = null; - }); - if (!_solveStatusNotified) { - notifySolveStatus(status, wideLayout); - _solveStatusNotified = true; - } - if (_solveStatus == null) { - _stopwatch.stop(); - _solveStatus = status; - - final curTask = widget.taskSource.task; - StatsDB().addTaskAttempt(curTask.rank, curTask.type, curTask.id, - status == VariationStatus.correct); - if (status == VariationStatus.correct) { - if (context.settings.sound) AudioController().correct(); - context.stats.incrementTotalPassCount(curTask.rank); - StatsDB().updateCollectionActiveSession( - widget.taskCollection.id, - correctDelta: 1, - durationDelta: _stopwatch.elapsed, - ); - } else { - if (context.settings.sound) AudioController().wrong(); - context.stats.incrementTotalFailCount(curTask.rank); - StatsDB().updateCollectionActiveSession( - widget.taskCollection.id, - wrongDelta: 1, - durationDelta: _stopwatch.elapsed, - ); - } - } - } - - _onShowContinuations() { - _continuationAnnotations = IMapOfSets.empty(); - for (final (p, st) - in _vtreeIt?.continuations() ?? <(wq.Point, VariationStatus)>[]) { - _continuationAnnotations = _continuationAnnotations?.add(p, ( - type: AnnotationShape.dot.u21, - color: switch (st) { - VariationStatus.correct => Colors.green, - VariationStatus.wrong => Colors.red, - }, - )); - } - if (_continuationAnnotations?.isNotEmpty ?? false) { - setState(() { - // Update board annotations - }); - } - } - - _onShare() { - final link = widget.taskSource.task.deepLink(); - Clipboard.setData(ClipboardData(text: link)).then((void _) { - if (context.mounted) notifyTaskLinkCopied(); - }); - } - _finishSession() { final activeSession = StatsDB().collectionActiveSession(widget.taskCollection.id)!; @@ -366,36 +277,39 @@ class _CollectionPageState extends State showDialog( context: context, barrierDismissible: false, - builder: (context) => AlertDialog( - title: Text(isNewBest ? 'New best!' : 'Result'), - icon: isNewBest ? const Icon(Icons.emoji_events) : null, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: const Text('Accuracy'), - trailing: Text( - '${curResult.correctCount}/${curResult.correctCount + curResult.wrongCount} (${(100 * curResult.correctCount / (curResult.correctCount + curResult.wrongCount)).round()}%)'), - ), - ListTile( - title: const Text('Total time'), - trailing: Text(curResult.duration.toString().split('.').first), - ), - ListTile( - title: const Text('Avg time per task'), - trailing: Text( - '${(curResult.duration.inSeconds / 10).toStringAsFixed(1)}s'), + builder: (context) { + final loc = AppLocalizations.of(context)!; + return AlertDialog( + title: Text(isNewBest ? loc.newBestResult : loc.result), + icon: isNewBest ? const Icon(Icons.emoji_events) : null, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(loc.accuracy), + trailing: Text( + '${curResult.correctCount}/${curResult.correctCount + curResult.wrongCount} (${(100 * curResult.correctCount / (curResult.correctCount + curResult.wrongCount)).round()}%)'), + ), + ListTile( + title: Text(loc.trainingTotalTime), + trailing: Text(curResult.duration.toString().split('.').first), + ), + ListTile( + title: Text(loc.trainingAvgTimePerTask), + trailing: Text( + '${(curResult.duration.inSeconds / (curResult.correctCount + curResult.wrongCount)).toStringAsFixed(1)}s'), + ), + ], + ), + actionsAlignment: MainAxisAlignment.center, + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(loc.exit), ), ], - ), - actionsAlignment: MainAxisAlignment.center, - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Exit'), - ), - ], - ), + ); + }, ).then((_) { if (context.mounted) Navigator.pop(context); }); @@ -408,11 +322,15 @@ class _SideBar extends StatelessWidget { final int taskCount; final wq.Color color; final VariationStatus? status; + final UpsolveMode upsolveMode; final Function()? onShowSolution; - final Function()? onShare; - final Function()? onReplay; - final Function()? onNext; + final Function()? onShareTask; + final Function()? onResetTask; + final Function()? onNextTask; final Function()? onExit; + final Function() onPreviousMove; + final Function() onNextMove; + final Function(UpsolveMode) onUpdateUpsolveMode; final Widget timeDisplay; const _SideBar({ @@ -421,11 +339,15 @@ class _SideBar extends StatelessWidget { required this.taskCount, required this.color, this.status, + required this.upsolveMode, required this.onShowSolution, - required this.onShare, - required this.onReplay, - required this.onNext, + required this.onShareTask, + required this.onResetTask, + required this.onNextTask, required this.onExit, + required this.onPreviousMove, + required this.onNextMove, + required this.onUpdateUpsolveMode, required this.timeDisplay, }); @@ -466,10 +388,14 @@ class _SideBar extends StatelessWidget { (status == null) ? Center(child: timeDisplay) : TaskActionBar( + upsolveMode: upsolveMode, onShowSolution: onShowSolution, - onShare: onShare, - onNext: onNext, - onReplay: onReplay, + onShareTask: onShareTask, + onNextTask: onNextTask, + onResetTask: onResetTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, ), ], ), diff --git a/lib/train/collections_page.dart b/lib/train/collections_page.dart index 7e315223..738438e9 100644 --- a/lib/train/collections_page.dart +++ b/lib/train/collections_page.dart @@ -2,22 +2,46 @@ import 'package:animated_tree_view/tree_view/tree_node.dart'; import 'package:animated_tree_view/tree_view/tree_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:wqhub/confirm_dialog.dart'; +import 'package:wqhub/help/collections_help_dialog.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/pop_and_window_class_aware_state.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/stats/stats_db.dart'; import 'package:wqhub/train/collection_page.dart'; import 'package:wqhub/train/task_repository.dart'; import 'package:wqhub/train/task_source/black_to_play_source.dart'; import 'package:wqhub/train/task_source/collection_task_source.dart'; -import 'package:wqhub/window_class_aware_state.dart'; -class CollectionsPage extends StatelessWidget { +class CollectionsPage extends StatefulWidget { + static const routeName = '/train/collections'; + const CollectionsPage({super.key}); + @override + State createState() => _CollectionsPageState(); +} + +class _CollectionsPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.settings.showCollectionsHelp) { + showDialog( + context: context, + builder: (context) => CollectionsHelpDialog(), + ); + } + }); + } + @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( - title: const Text('Collections'), + title: Text(loc.collections), ), body: TreeView.simpleTyped>( tree: TaskRepository().collectionsTreeNode(), @@ -39,9 +63,11 @@ class _CollectionTile extends StatefulWidget { State<_CollectionTile> createState() => _CollectionTileState(); } -class _CollectionTileState extends WindowClassAwareState<_CollectionTile> { +class _CollectionTileState + extends PopAndWindowClassAwareState<_CollectionTile> { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; if (isWindowClassCompact) { return Slidable( key: ValueKey('collection_tile.${widget.collection.id}'), @@ -51,15 +77,15 @@ class _CollectionTileState extends WindowClassAwareState<_CollectionTile> { SlidableAction( backgroundColor: Colors.green, icon: Icons.flag, - label: 'Start', + label: loc.start, padding: EdgeInsets.all(8), - onPressed: (context) => onStart(), + onPressed: (context) => onStart(loc), ), if (StatsDB().collectionActiveSession(widget.collection.id) != null) SlidableAction( backgroundColor: Colors.blue, icon: Icons.double_arrow, - label: 'Continue', + label: loc.continue_, padding: EdgeInsets.all(8), onPressed: (context) => onContinue(), ), @@ -82,14 +108,14 @@ class _CollectionTileState extends WindowClassAwareState<_CollectionTile> { mainAxisSize: MainAxisSize.min, children: [ ElevatedButton.icon( - label: const Text('Start'), + label: Text(loc.start), icon: const Icon(Icons.flag), - onPressed: onStart, + onPressed: () => onStart(loc), ), if (StatsDB().collectionActiveSession(widget.collection.id) != null) ElevatedButton.icon( - label: const Text('Continue'), + label: Text(loc.continue_), icon: const Icon(Icons.double_arrow), onPressed: onContinue, ), @@ -98,8 +124,21 @@ class _CollectionTileState extends WindowClassAwareState<_CollectionTile> { )); } - onStart() { - StatsDB().resetCollectionActiveSession(widget.collection.id); + onStart(AppLocalizations loc) { + if (StatsDB().collectionActiveSession(widget.collection.id) != null) { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: loc.confirm, + content: loc.msgConfirmDeleteCollectionProgress, + onYes: () { + Navigator.pop(context); + StatsDB().resetCollectionActiveSession(widget.collection.id); + onContinue(); + }, + onNo: () => Navigator.pop(context))); + return; + } onContinue(); } @@ -113,25 +152,22 @@ class _CollectionTileState extends WindowClassAwareState<_CollectionTile> { duration: Duration.zero, ); final currentTask = activeSession.correctCount + activeSession.wrongCount; - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: CollectionPage( - taskCollection: widget.collection, - taskSource: BlackToPlaySource( - source: - CollectionTaskSource(widget.collection, offset: currentTask), - blackToPlay: context.settings.alwaysBlackToPlay, - ), - initialTask: currentTask + 1, - ), + CollectionPage.routeName, + arguments: CollectionRouteArguments( + taskCollection: widget.collection, + taskSource: BlackToPlaySource( + source: CollectionTaskSource(widget.collection, offset: currentTask), + blackToPlay: context.settings.alwaysBlackToPlay, ), + initialTask: currentTask + 1, ), ); } - String collectionSubtitle() => - '${widget.collection.taskCount} tasks\nBest result: ${StatsDB().collectionStat(widget.collection.id) ?? '-'}'; + String collectionSubtitle() { + final loc = AppLocalizations.of(context)!; + return '${loc.nTasks(widget.collection.taskCount)}\n${loc.bestResult}: ${StatsDB().collectionStat(widget.collection.id) ?? '-'}'; + } } diff --git a/lib/train/custom_exam_page.dart b/lib/train/custom_exam_page.dart new file mode 100644 index 00000000..c5e73319 --- /dev/null +++ b/lib/train/custom_exam_page.dart @@ -0,0 +1,104 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; +import 'package:wqhub/stats/stats_db.dart'; +import 'package:wqhub/train/custom_exam_selection_page.dart'; +import 'package:wqhub/train/exam_page.dart'; +import 'package:wqhub/train/rank_range.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_source/black_to_play_source.dart'; +import 'package:wqhub/train/task_source/const_task_ref_source.dart'; +import 'package:wqhub/train/task_source/task_source.dart'; +import 'package:wqhub/train/task_source/task_source_type.dart'; +import 'package:wqhub/train/task_tag.dart'; +import 'package:wqhub/train/task_type.dart'; + +class CustomExamRouteArguments { + final int taskCount; + final Duration timePerTask; + final RankRange rankRange; + final int maxMistakes; + final TaskSourceType taskSourceType; + final ISet? taskTypes; + final TaskTag? taskTag; + final bool collectStats; + + const CustomExamRouteArguments( + {required this.taskCount, + required this.timePerTask, + required this.rankRange, + required this.maxMistakes, + required this.taskSourceType, + required this.taskTypes, + required this.taskTag, + required this.collectStats}); +} + +class CustomExamPage extends StatelessWidget { + static const routeName = '/train/custom_exam'; + + final int taskCount; + final Duration timePerTask; + final RankRange rankRange; + final int maxMistakes; + final TaskSourceType taskSourceType; + final ISet? taskTypes; + final TaskTag? taskTag; + final bool collectStats; + + const CustomExamPage( + {super.key, + required this.taskCount, + required this.timePerTask, + required this.rankRange, + required this.maxMistakes, + required this.taskSourceType, + this.taskTypes, + this.taskTag, + required this.collectStats}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return ExamPage( + title: loc.customExam, + examEvent: ExamEvent(type: ExamType.custom), + rankRange: rankRange, + taskCount: taskCount, + timePerTask: timePerTask, + maxMistakes: maxMistakes, + createTaskSource: createTaskSource, + onPass: () {}, + onFail: () {}, + baseRoute: routeName, + exitRoute: CustomExamSelectionPage.routeName, + redoRouteArguments: CustomExamRouteArguments( + taskCount: taskCount, + timePerTask: timePerTask, + rankRange: rankRange, + maxMistakes: maxMistakes, + taskSourceType: taskSourceType, + taskTypes: taskTypes, + taskTag: taskTag, + collectStats: collectStats, + ), + collectStats: collectStats, + ); + } + + TaskSource createTaskSource(BuildContext context) { + final taskSource = switch (taskSourceType) { + TaskSourceType.fromTaskTypes => + TaskRepository().taskSourceByTypes(rankRange, taskTypes!), + TaskSourceType.fromTaskTag => + TaskRepository().taskSourceByTag(rankRange, taskTag!), + TaskSourceType.fromMistakes => ConstTaskRefSource( + taskRefs: StatsDB().mistakesByRankRange(rankRange, taskCount)), + }; + return BlackToPlaySource( + source: taskSource, + blackToPlay: context.settings.alwaysBlackToPlay, + ); + } +} diff --git a/lib/train/custom_exam_selection_page.dart b/lib/train/custom_exam_selection_page.dart new file mode 100644 index 00000000..8cef341e --- /dev/null +++ b/lib/train/custom_exam_selection_page.dart @@ -0,0 +1,276 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:wqhub/input/duration_form_field.dart'; +import 'package:wqhub/input/int_form_field.dart'; +import 'package:wqhub/input/rank_range_form_field.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/pop_aware_state.dart'; +import 'package:wqhub/stats/stats_db.dart'; +import 'package:wqhub/train/custom_exam_page.dart'; +import 'package:wqhub/train/rank_range.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_source/task_source_type.dart'; +import 'package:wqhub/train/task_tag.dart'; +import 'package:wqhub/train/task_type.dart'; +import 'package:wqhub/wq/rank.dart'; + +class CustomExamSelectionPage extends StatefulWidget { + static const routeName = '/train/custom_exam_selection'; + + @override + State createState() => + _CustomExamSelectionPageState(); +} + +class _CustomExamSelectionPageState + extends PopAwareState { + final _formKey = GlobalKey(); + var _taskCount = 10; + var _maxMistakes = 2; + var _timePerTask = Duration(seconds: 45); + var _collectStats = true; + var _rankRange = RankRange(from: Rank.k15, to: Rank.d7); + var _taskSourceType = TaskSourceType.values.first; + var _selectedTaskTypes = {TaskType.lifeAndDeath, TaskType.tesuji}; + var _tag = TaskTag.beginner; + var _subtag = TaskTag.captureInOneMove; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final taskCount = availableTasks(); + final availableTasksText = Text(loc.nTasksAvailable(taskCount)); + return Scaffold( + appBar: AppBar( + title: Text(loc.customExam), + ), + body: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 600), + child: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + spacing: 8.0, + children: [ + IntFormField( + label: loc.numberOfTasks, + initialValue: _taskCount, + minValue: 1, + maxValue: 1000, + onChanged: (value) { + _taskCount = value; + }, + ), + IntFormField( + label: loc.maxNumberOfMistakes, + initialValue: _maxMistakes, + minValue: 0, + maxValue: 1 << 30, + onChanged: (value) { + _maxMistakes = value; + }, + ), + DurationFormField( + label: loc.timePerTask, + initialValue: _timePerTask, + validator: (duration) { + if (duration! == Duration.zero) + return 'Must be greater than zero'; + if (duration <= Duration.zero) + return 'Must be positive'; + return null; + }, + onChanged: (value) { + _timePerTask = value; + }, + ), + RankRangeFormField( + initialValue: _rankRange, + validator: (rankRange) { + if (rankRange!.from.index > rankRange.to.index) { + return 'Min rank must be less or equal than max rank'; + } + return null; + }, + onChanged: (RankRange newRange) { + setState(() { + _rankRange = newRange; + }); + }, + ), + DropdownButtonFormField( + initialValue: _taskSourceType, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: loc.taskSource, + ), + items: [ + for (final value in TaskSourceType.values) + DropdownMenuItem( + value: value, + child: Text(value.toLocalizedString(loc)), + ), + ], + onChanged: (value) { + if (value != null) { + setState(() { + _taskSourceType = value; + }); + } + }, + ), + ...switch (_taskSourceType) { + TaskSourceType.fromTaskTypes => [ + Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: [ + for (final taskType in TaskType.values) + FilterChip( + label: Text(taskType.toLocalizedString(loc)), + selected: + _selectedTaskTypes.contains(taskType), + onSelected: (bool selected) { + setState(() { + if (selected) + _selectedTaskTypes.add(taskType); + else + _selectedTaskTypes.remove(taskType); + }); + }, + ) + ], + ), + availableTasksText, + ], + TaskSourceType.fromTaskTag => [ + DropdownButtonFormField( + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: loc.topic, + ), + initialValue: _tag, + items: [ + for (final tag in TaskTag.values + .where((t) => t.subtags().isNotEmpty)) + DropdownMenuItem( + value: tag, + child: Text(tag.toLocalizedString(loc)), + ) + ], + onChanged: (value) { + if (value != null) { + setState(() { + _tag = value; + _subtag = _tag.subtags().first; + }); + } + }, + ), + DropdownButtonFormField( + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: loc.subtopic, + ), + initialValue: _subtag, + items: [ + for (final tag in _tag + .subtags() + .where((t) => t.ranks().isNotEmpty)) + DropdownMenuItem( + value: tag, + child: Text(tag.toLocalizedString(loc)), + ) + ], + onChanged: (value) { + if (value != null) { + setState(() { + _subtag = value; + }); + } + }, + ), + availableTasksText, + ], + TaskSourceType.fromMistakes => [ + availableTasksText, + ], + }, + CheckboxListTile( + title: Text(loc.collectStats), + value: _collectStats, + onChanged: (value) { + if (value != null) { + setState(() { + _collectStats = value; + }); + } + }, + ), + ], + ), + ), + ), + ), + ), + ), + bottomNavigationBar: BottomAppBar( + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 400), + child: Row( + children: [ + Expanded( + child: FilledButton( + child: Text(loc.start), + onPressed: taskCount == 0 + ? null + : () { + if (_formKey.currentState!.validate()) { + Navigator.pushNamed( + context, + CustomExamPage.routeName, + arguments: CustomExamRouteArguments( + taskCount: _taskCount, + timePerTask: _timePerTask, + rankRange: _rankRange, + maxMistakes: _maxMistakes, + taskSourceType: _taskSourceType, + taskTypes: ISet(_selectedTaskTypes), + taskTag: _subtag, + collectStats: _collectStats, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Invalid exam settings. Please fix the errors.'), + showCloseIcon: true, + ), + ); + } + }, + ), + ), + ], + ), + ), + ), + ), + ); + } + + int availableTasks() => switch (_taskSourceType) { + TaskSourceType.fromTaskTypes => + TaskRepository().countByTypes(_rankRange, ISet(_selectedTaskTypes)), + TaskSourceType.fromTaskTag => + TaskRepository().countByTag(_rankRange, _subtag), + TaskSourceType.fromMistakes => + StatsDB().countMistakesByRange(_rankRange), + }; +} diff --git a/lib/train/endgame_exam_page.dart b/lib/train/endgame_exam_page.dart index 87b26164..6e94f419 100644 --- a/lib/train/endgame_exam_page.dart +++ b/lib/train/endgame_exam_page.dart @@ -1,557 +1,64 @@ import 'dart:math'; -import 'package:extension_type_unions/extension_type_unions.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:wqhub/audio/audio_controller.dart'; -import 'package:wqhub/board/board_annotation.dart'; -import 'package:wqhub/game_client/time_state.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; -import 'package:wqhub/confirm_dialog.dart'; import 'package:wqhub/stats/stats_db.dart'; -import 'package:wqhub/train/response_delay.dart'; -import 'package:wqhub/train/solve_status_notifier.dart'; -import 'package:wqhub/train/task_action_bar.dart'; +import 'package:wqhub/train/endgame_exam_selection_page.dart'; +import 'package:wqhub/train/exam_page.dart'; +import 'package:wqhub/train/rank_range.dart'; import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_source/black_to_play_source.dart'; import 'package:wqhub/train/task_source/const_task_source.dart'; -import 'package:wqhub/train/task_type.dart'; -import 'package:wqhub/turn_icon.dart'; -import 'package:wqhub/wq/annotated_game_tree.dart'; -import 'package:wqhub/board/board.dart'; -import 'package:wqhub/board/board_settings.dart'; -import 'package:wqhub/board/coordinate_style.dart'; import 'package:wqhub/train/task_source/task_source.dart'; -import 'package:wqhub/time_display.dart'; -import 'package:wqhub/train/variation_tree.dart'; +import 'package:wqhub/train/task_type.dart'; import 'package:wqhub/wq/rank.dart'; -import 'package:wqhub/wq/wq.dart' as wq; - -class EndgameExamPage extends StatefulWidget { - const EndgameExamPage( - {super.key, required this.rank, required this.taskSource}); +class EndgameExamRouteArguments { final Rank rank; - final TaskSource taskSource; - @override - State createState() => _EndgameExamPageState(); + const EndgameExamRouteArguments({required this.rank}); } -const _timePerTask = Duration(seconds: 45); +class EndgameExamPage extends StatelessWidget { + static const routeName = '/train/endgame_exam'; -class _EndgameExamPageState extends State - with SolveStatusNotifier { - final _timeDisplayKey = GlobalKey(debugLabel: 'time-display'); - final _stopwatch = Stopwatch(); - VariationTreeIterator? _vtreeIt; - var _gameTree = AnnotatedGameTree(19); - var _turn = wq.Color.black; - var _taskNumber = 1; - VariationStatus? _solveStatus; - bool _solveStatusNotified = false; - var _totalTime = Duration.zero; - var _mistakeCount = 0; - IMapOfSets? _continuationAnnotations; - - @override - void initState() { - super.initState(); - _stopwatch.start(); - _setupCurrentTask(); - } - - @override - void dispose() { - _stopwatch.stop(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; - final borderSize = - 1.5 * (Theme.of(context).textTheme.labelMedium?.fontSize ?? 0); - final border = context.settings.showCoordinates - ? BoardBorderSettings( - size: borderSize, - color: Theme.of(context).colorScheme.surfaceContainerHigh, - rowCoordinates: CoordinateStyle( - labels: CoordinateLabels.numbers, - reverse: true, - ), - columnCoordinates: CoordinateStyle( - labels: CoordinateLabels.alphaNoI, - ), - ) - : null; + static const taskTypes = const ISetConst({TaskType.endgame}); + static const taskCount = 10; + static const timePerTask = const Duration(seconds: 45); + static const maxMistakes = 2; - final boardSettings = BoardSettings( - size: widget.taskSource.task.boardSize, - subBoard: SubBoard( - topLeft: widget.taskSource.task.topLeft, - size: widget.taskSource.task.subBoardSize, - ), - theme: context.settings.boardTheme, - edgeLine: context.settings.edgeLine, - border: border, - stoneShadows: context.settings.stoneShadows, - ); - - final board = LayoutBuilder( - builder: (context, constraints) { - final boardSize = constraints.biggest.shortestSide - - 2 * (boardSettings.border?.size ?? 0); - return Board( - size: boardSize, - settings: boardSettings, - onPointClicked: (p) => _onPointClicked(p, wideLayout), - turn: _turn, - stones: _gameTree.stones, - annotations: _continuationAnnotations ?? _gameTree.annotations, - confirmTap: context.settings.confirmMoves, - ); - }, - ); - - final taskTitle = - '[${widget.taskSource.task.rank.toString()}] ${widget.taskSource.task.type.toString()}'; - - final timeDisplay = TimeDisplay( - key: _timeDisplayKey, - timeState: const TimeState( - mainTimeLeft: _timePerTask, - periodTimeLeft: Duration.zero, - periodCount: 0, - ), - warningDuration: const Duration(seconds: 9), - enabled: _solveStatus == null, - tickerEnabled: true, - voiceCountdown: false, - onTimeout: () => _onTimeout(wideLayout), - ); - - if (wideLayout) { - return Scaffold( - body: Center( - child: Row( - children: [ - Expanded(child: Center(child: board)), - VerticalDivider(thickness: 1, width: 8), - _SideBar( - taskTitle: taskTitle, - taskNumber: _taskNumber, - color: widget.taskSource.task.first, - status: _solveStatus, - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, - onCancelExam: () { - context.stats.incrementEndgameExamFailCount(widget.rank); - Navigator.popUntil(context, (route) => route.isFirst); - }, - timeDisplay: timeDisplay, - ), - ], - ), - ), - ); - } else { - return Scaffold( - appBar: AppBar( - leading: Center(child: Text('$_taskNumber/10')), - title: Row( - spacing: 4, - children: [ - TurnIcon(color: widget.taskSource.task.first), - Text(taskTitle), - ], - ), - actions: [ - IconButton( - icon: Icon(Icons.cancel), - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Confirm', - content: - 'Are you sure that you want to stop the Endgame Exam?', - onYes: () { - context.stats.incrementEndgameExamFailCount(widget.rank); - Navigator.popUntil(context, (route) => route.isFirst); - }, - onNo: () { - Navigator.pop(context); - }, - ), - ); - }, - ), - ], - ), - body: Center( - child: board, - ), - bottomNavigationBar: BottomAppBar( - child: (_solveStatus == null) - ? Center(child: timeDisplay) - : TaskActionBar( - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, - ), - ), - ); - } - } - - void _onReplay() { - setState(() { - _setupCurrentTask(); - }); - } - - void _onNext() { - if (_taskNumber == 10) { - if (_mistakeCount <= 2) { - context.stats.incrementEndgameExamPassCount(widget.rank); - } else { - context.stats.incrementEndgameExamFailCount(widget.rank); - } - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => _ResultDialog( - totalTime: _totalTime, - mistakeCount: _mistakeCount, - onExit: () => Navigator.popUntil(context, (route) => route.isFirst), - onRedo: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: EndgameExamPage( - rank: widget.rank, - taskSource: ConstTaskSource( - tasks: TaskRepository().read(widget.rank, - const ISetConst({TaskType.endgame}), 10)), - ), - ), - ), - ); - }, - onNext: _mistakeCount <= 2 - ? () { - final nextRank = - Rank.values[min(widget.rank.index + 1, Rank.p10.index)]; - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: EndgameExamPage( - rank: nextRank, - taskSource: ConstTaskSource( - tasks: TaskRepository().read(nextRank, - const ISetConst({TaskType.endgame}), 10)), - ), - ), - ), - ); - } - : null, - ), - ); - return; - } - widget.taskSource - .next(_solveStatus ?? VariationStatus.wrong, _stopwatch.elapsed); - _taskNumber++; - _solveStatus = null; - setState(() { - _setupCurrentTask(); - }); - _stopwatch.reset(); - _stopwatch.start(); - } - - void _setupCurrentTask() { - _continuationAnnotations = null; - _vtreeIt = - VariationTreeIterator(tree: widget.taskSource.task.variationTree); - _gameTree = AnnotatedGameTree(widget.taskSource.task.boardSize); - for (final entry in widget.taskSource.task.initialStones.entries) { - for (final p in entry.value) { - _gameTree - .moveAnnotated((col: entry.key, p: p), mode: AnnotationMode.none); - } - } - _turn = widget.taskSource.task.first; - _solveStatusNotified = false; - } - - void _onPointClicked(wq.Point p, bool wideLayout) { - if (!(_solveStatusNotified || _turn == widget.taskSource.task.first)) { - return; - } - - if (_gameTree.moveAnnotated((col: _turn, p: p), - mode: AnnotationMode.variation) != - null) { - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } - _continuationAnnotations = null; - final status = _vtreeIt!.move(p); - _turn = _turn.opposite; - if (status != null) { - _setSolveStatus(status, wideLayout); - } else { - switch (context.settings.responseDelay) { - case ResponseDelay.none: - _generateResponseMove(wideLayout); - default: - Future.delayed(context.settings.responseDelay.duration, () { - _generateResponseMove(wideLayout); - }); - } - } - setState(() {/* Update board */}); - } - } - - void _generateResponseMove(bool wideLayout) { - if (_solveStatusNotified) return; - - final resp = _vtreeIt!.genMove(); - _gameTree - .moveAnnotated((col: _turn, p: resp), mode: AnnotationMode.variation); - _turn = _turn.opposite; - final status = _vtreeIt!.move(resp); - if (status != null) { - _setSolveStatus(status, wideLayout); - } - setState(() {/* Update board */}); - } - - void _setSolveStatus(VariationStatus status, bool wideLayout) { - setState(() { - _continuationAnnotations = null; - }); - if (!_solveStatusNotified) { - notifySolveStatus(status, wideLayout); - _solveStatusNotified = true; - } - if (_solveStatus == null) { - final curTask = widget.taskSource.task; - StatsDB().addTaskAttempt(curTask.rank, curTask.type, curTask.id, - status == VariationStatus.correct); - if (status == VariationStatus.correct) { - if (context.settings.sound) AudioController().correct(); - context.stats.incrementTotalPassCount(widget.rank); - } else { - if (context.settings.sound) AudioController().wrong(); - context.stats.incrementTotalFailCount(widget.rank); - _mistakeCount++; - } - _stopwatch.stop(); - _totalTime += _stopwatch.elapsed; - _solveStatus = status; - } - } - - void _onTimeout(bool wideLayout) { - if (_solveStatus == null) { - _mistakeCount++; - _totalTime += _timePerTask; - _solveStatus = VariationStatus.wrong; - if (!_solveStatusNotified) { - notifySolveTimeout(wideLayout); - _solveStatusNotified = true; - } - setState(() {}); - } - } - - _onShowContinuations() { - _continuationAnnotations = IMapOfSets.empty(); - for (final (p, st) - in _vtreeIt?.continuations() ?? <(wq.Point, VariationStatus)>[]) { - _continuationAnnotations = _continuationAnnotations?.add(p, ( - type: AnnotationShape.dot.u21, - color: switch (st) { - VariationStatus.correct => Colors.green, - VariationStatus.wrong => Colors.red, - }, - )); - } - if (_continuationAnnotations?.isNotEmpty ?? false) { - setState(() { - // Update board annotations - }); - } - } - - _onShare() { - final link = widget.taskSource.task.deepLink(); - Clipboard.setData(ClipboardData(text: link)).then((void _) { - if (context.mounted) notifyTaskLinkCopied(); - }); - } -} - -class _SideBar extends StatelessWidget { - final String taskTitle; - final int taskNumber; - final wq.Color color; - final VariationStatus? status; - final Function()? onShowSolution; - final Function()? onShare; - final Function()? onReplay; - final Function()? onNext; - final Function() onCancelExam; - final Widget timeDisplay; + final Rank rank; - const _SideBar({ - required this.taskTitle, - required this.taskNumber, - required this.color, - this.status, - required this.onShowSolution, - required this.onShare, - required this.onReplay, - required this.onNext, - required this.onCancelExam, - required this.timeDisplay, - }); + const EndgameExamPage({super.key, required this.rank}); @override Widget build(BuildContext context) { - final widgetSize = MediaQuery.sizeOf(context); - final sidebarSize = min( - widgetSize.longestSide - widgetSize.shortestSide, widgetSize.width / 3); - return SizedBox( - width: sidebarSize, - child: Container( - padding: EdgeInsets.all(8), - color: ColorScheme.of(context).surfaceContainer, - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Text( - '$taskNumber/10', - textAlign: TextAlign.center, - )), - IconButton( - icon: Icon(Icons.cancel), - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Confirm', - content: - 'Are you sure that you want to stop the Endgame Exam?', - onYes: onCancelExam, - onNo: () { - Navigator.pop(context); - }, - ), - ); - }, - ), - ], - ), - Row( - spacing: 8, - mainAxisSize: MainAxisSize.min, - children: [ - TurnIcon(color: color), - Text(taskTitle), - ], - ), - Expanded(child: Container()), - (status == null) - ? Center( - child: timeDisplay, - ) - : TaskActionBar( - onShowSolution: onShowSolution, - onShare: onShare, - onNext: onNext, - onReplay: onReplay, - ), - ], - ), - ), + final loc = AppLocalizations.of(context)!; + final nextRank = Rank.values[min(rank.index + 1, Rank.p10.index)]; + return ExamPage( + title: loc.endgameExam, + examEvent: ExamEvent(type: ExamType.endgame), + rankRange: RankRange.single(rank), + taskCount: taskCount, + timePerTask: timePerTask, + maxMistakes: maxMistakes, + createTaskSource: createTaskSource, + onPass: () => context.stats.incrementEndgameExamPassCount(rank), + onFail: () => context.stats.incrementEndgameExamFailCount(rank), + baseRoute: routeName, + exitRoute: EndgameExamSelectionPage.routeName, + redoRouteArguments: EndgameExamRouteArguments(rank: rank), + nextRouteArguments: EndgameExamRouteArguments(rank: nextRank), ); } -} - -class _ResultDialog extends StatelessWidget { - final Duration totalTime; - final int mistakeCount; - final Function() onExit; - final Function() onRedo; - final Function()? onNext; - const _ResultDialog( - {required this.totalTime, - required this.mistakeCount, - required this.onExit, - required this.onRedo, - required this.onNext}); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: (mistakeCount <= 2) - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.verified), const Text('Passed')], - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.cancel), const Text('Failed')], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: const Text('Total time'), - trailing: Text(totalTime.toString().substring(2, 7)), - ), - ListTile( - title: const Text('Avg time per task'), - trailing: Text('${(totalTime.inSeconds / 10).toStringAsFixed(1)}s'), - ), - ListTile( - title: const Text('Mistakes'), - trailing: Text('$mistakeCount'), - ), - ], - ), - actionsAlignment: MainAxisAlignment.center, - actions: [ - TextButton( - onPressed: onExit, - child: const Text('Exit'), - ), - TextButton( - onPressed: onRedo, - child: const Text('Redo'), - ), - if (onNext != null) - TextButton( - onPressed: onNext, - child: const Text('Next'), - ), - ], + TaskSource createTaskSource(BuildContext context) { + return BlackToPlaySource( + source: ConstTaskSource( + tasks: TaskRepository().readByTypes(rank, taskTypes, taskCount)), + blackToPlay: context.settings.alwaysBlackToPlay, ); } } diff --git a/lib/train/endgame_exam_selection_page.dart b/lib/train/endgame_exam_selection_page.dart index 15a69431..d6039d8c 100644 --- a/lib/train/endgame_exam_selection_page.dart +++ b/lib/train/endgame_exam_selection_page.dart @@ -2,19 +2,20 @@ import 'dart:math'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; +import 'package:wqhub/help/endgame_exam_help_dialog.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/pop_and_window_class_aware_state.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/train/endgame_exam_page.dart'; import 'package:wqhub/train/endgame_exam_ranks.dart'; import 'package:wqhub/train/exam_rank_card.dart'; import 'package:wqhub/train/rank_range.dart'; -import 'package:wqhub/train/task_repository.dart'; -import 'package:wqhub/train/task_source/black_to_play_source.dart'; -import 'package:wqhub/train/task_source/const_task_source.dart'; -import 'package:wqhub/train/task_type.dart'; import 'package:wqhub/window_class_aware_state.dart'; import 'package:wqhub/wq/rank.dart'; class EndgameExamSelectionPage extends StatefulWidget { + static const routeName = '/train/endgame_exam_selection'; + const EndgameExamSelectionPage({super.key}); @override @@ -23,13 +24,27 @@ class EndgameExamSelectionPage extends StatefulWidget { } class _EndgameExamSelectionPageState - extends WindowClassAwareState { + extends PopAndWindowClassAwareState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.settings.showEndgameExamHelp) { + showDialog( + context: context, + builder: (context) => EndgameExamHelpDialog(), + ); + } + }); + } + @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final stats = loadStats(); return Scaffold( appBar: AppBar( - title: const Text('Endgame exam'), + title: Text(loc.endgameExam), ), body: Center( child: ConstrainedBox( @@ -57,23 +72,10 @@ class _EndgameExamSelectionPageState 0) > 0), onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: EndgameExamPage( - rank: rank, - taskSource: BlackToPlaySource( - source: ConstTaskSource( - tasks: TaskRepository().read(rank, - const ISetConst({TaskType.endgame}), 10), - ), - blackToPlay: context.settings.alwaysBlackToPlay, - ), - ), - ), - ), + EndgameExamPage.routeName, + arguments: EndgameExamRouteArguments(rank: rank), ); }, ), diff --git a/lib/train/exam_page.dart b/lib/train/exam_page.dart new file mode 100644 index 00000000..e4f10419 --- /dev/null +++ b/lib/train/exam_page.dart @@ -0,0 +1,526 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:wqhub/game_client/time_state.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; +import 'package:wqhub/confirm_dialog.dart'; +import 'package:wqhub/stats/stats_db.dart'; +import 'package:wqhub/train/rank_range.dart'; +import 'package:wqhub/train/task_action_bar.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_solving_state_mixin.dart'; +import 'package:wqhub/train/upsolve_mode.dart'; +import 'package:wqhub/turn_icon.dart'; +import 'package:wqhub/board/board.dart'; +import 'package:wqhub/board/board_settings.dart'; +import 'package:wqhub/board/coordinate_style.dart'; +import 'package:wqhub/train/task_source/task_source.dart'; +import 'package:wqhub/time_display.dart'; +import 'package:wqhub/train/variation_tree.dart'; +import 'package:wqhub/wq/wq.dart' as wq; + +class ExamPage extends StatefulWidget { + const ExamPage({ + super.key, + required this.title, + required this.examEvent, + required this.rankRange, + required this.taskCount, + required this.timePerTask, + required this.maxMistakes, + required this.createTaskSource, + required this.onPass, + required this.onFail, + required this.baseRoute, + required this.exitRoute, + required this.redoRouteArguments, + this.nextRouteArguments, + this.collectStats = true, + }); + + final String title; + final ExamEvent examEvent; + final RankRange rankRange; + final int taskCount; + final Duration timePerTask; + final int maxMistakes; + final TaskSource Function(BuildContext) createTaskSource; + final Function() onPass; + final Function() onFail; + final String baseRoute; + final String exitRoute; + final dynamic redoRouteArguments; + final dynamic nextRouteArguments; + final bool collectStats; + + @override + State createState() => _ExamPageState(); +} + +class _ExamPageState extends State with TaskSolvingStateMixin { + final _timeDisplayKey = GlobalKey(debugLabel: 'time-display'); + final _stopwatch = Stopwatch(); + late final _taskSource = widget.createTaskSource(context); + var _taskNumber = 1; + var _totalTime = Duration.zero; + var _mistakeCount = 0; + + @override + void initState() { + super.initState(); + _stopwatch.start(); + } + + @override + void dispose() { + _stopwatch.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; + final borderSize = + 1.5 * (Theme.of(context).textTheme.labelMedium?.fontSize ?? 0); + final border = context.settings.showCoordinates + ? BoardBorderSettings( + size: borderSize, + color: Theme.of(context).colorScheme.surfaceContainerHigh, + rowCoordinates: CoordinateStyle( + labels: CoordinateLabels.numbers, + reverse: true, + ), + columnCoordinates: CoordinateStyle( + labels: CoordinateLabels.alphaNoI, + ), + ) + : null; + + final boardSettings = BoardSettings( + size: _taskSource.task.boardSize, + subBoard: SubBoard( + topLeft: _taskSource.task.topLeft, + size: _taskSource.task.subBoardSize, + ), + theme: context.settings.boardTheme, + edgeLine: context.settings.edgeLine, + border: border, + stoneShadows: context.settings.stoneShadows, + ); + + final board = LayoutBuilder( + builder: (context, constraints) { + final boardSize = constraints.biggest.shortestSide - + 2 * (boardSettings.border?.size ?? 0); + return Board( + size: boardSize, + settings: boardSettings, + onPointClicked: (p) => onMove(p, wideLayout), + turn: turn, + stones: gameTree.stones, + annotations: continuationAnnotations ?? gameTree.annotations, + confirmTap: context.settings.confirmMoves, + ); + }, + ); + + final taskTitle = + '[${_taskSource.task.rank.toString()}] ${_taskSource.task.type.toLocalizedString(loc)}'; + + final timeDisplay = TimeDisplay( + key: _timeDisplayKey, + timeState: TimeState( + mainTimeLeft: widget.timePerTask, + periodTimeLeft: Duration.zero, + periodCount: 0, + ), + warningDuration: const Duration(seconds: 9), + enabled: solveStatus == null, + tickerEnabled: true, + voiceCountdown: false, + onTimeout: () => _onSolveTimeout(wideLayout), + ); + + if (wideLayout) { + return Scaffold( + body: Center( + child: Row( + children: [ + Expanded(child: Center(child: board)), + VerticalDivider(thickness: 1, width: 8), + _SideBar( + title: widget.title, + taskTitle: taskTitle, + taskNumber: _taskNumber, + taskCount: widget.taskCount, + color: _taskSource.task.first, + status: solveStatus, + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onResetTask: onResetTask, + onNextTask: _onNext, + onCancelExam: () { + widget.onFail(); + if (widget.collectStats) { + final curCount = + _taskNumber - (solveStatus == null ? 1 : 0); + StatsDB().addExamAttempt( + widget.examEvent, + widget.rankRange, + curCount - _mistakeCount, + _mistakeCount + widget.taskCount - curCount, + false, + _totalTime); + } + Navigator.popUntil( + context, ModalRoute.withName(widget.exitRoute)); + }, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, + timeDisplay: timeDisplay, + ), + ], + ), + ), + ); + } else { + return Scaffold( + appBar: AppBar( + leading: Center(child: Text('$_taskNumber/${widget.taskCount}')), + title: Row( + spacing: 4, + children: [ + TurnIcon(color: _taskSource.task.first), + Text(taskTitle), + ], + ), + actions: [ + IconButton( + icon: Icon(Icons.cancel), + onPressed: () { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: loc.confirm, + content: loc.msgConfirmStopEvent(widget.title), + onYes: () { + widget.onFail(); + if (widget.collectStats) { + final curCount = + _taskNumber - (solveStatus == null ? 1 : 0); + StatsDB().addExamAttempt( + widget.examEvent, + widget.rankRange, + curCount - _mistakeCount, + _mistakeCount + widget.taskCount - curCount, + false, + _totalTime); + } + Navigator.popUntil( + context, ModalRoute.withName(widget.exitRoute)); + }, + onNo: () { + Navigator.pop(context); + }, + ), + ); + }, + ), + ], + ), + body: Center( + child: board, + ), + bottomNavigationBar: BottomAppBar( + height: upsolveMode == UpsolveMode.auto ? 80.0 : 160.0, + child: (solveStatus == null) + ? Center(child: timeDisplay) + : TaskActionBar( + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onResetTask: onResetTask, + onNextTask: _onNext, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, + ), + ), + ); + } + } + + @override + Task get currentTask => _taskSource.task; + + @override + void onSolveStatus(VariationStatus status) { + _stopwatch.stop(); + _totalTime += _stopwatch.elapsed; + if (status != VariationStatus.correct) _mistakeCount++; + + if (widget.collectStats) { + StatsDB().addTaskAttempt(currentTask.rank, currentTask.type, + currentTask.id, status == VariationStatus.correct); + if (status == VariationStatus.correct) { + context.stats.incrementTotalPassCount(currentTask.rank); + } else { + context.stats.incrementTotalFailCount(currentTask.rank); + } + } + } + + void _onNext() { + if (_taskNumber == widget.taskCount) { + final passed = _mistakeCount <= widget.maxMistakes; + if (passed) { + widget.onPass(); + } else { + widget.onFail(); + } + if (widget.collectStats) { + StatsDB().addExamAttempt( + widget.examEvent, + widget.rankRange, + widget.taskCount - _mistakeCount, + _mistakeCount, + passed, + _totalTime); + } + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _ResultDialog( + totalTime: _totalTime, + passed: passed, + mistakeCount: _mistakeCount, + taskCount: widget.taskCount, + onExit: () => Navigator.popUntil( + context, ModalRoute.withName(widget.exitRoute)), + onRedo: () { + Navigator.pop(context); + Navigator.pushReplacementNamed( + context, + widget.baseRoute, + arguments: widget.redoRouteArguments, + ); + }, + onNext: _mistakeCount <= widget.maxMistakes && + widget.nextRouteArguments != null + ? () { + Navigator.pop(context); + Navigator.pushReplacementNamed( + context, + widget.baseRoute, + arguments: widget.nextRouteArguments, + ); + } + : null, + ), + ); + return; + } + _taskSource.next(solveStatus ?? VariationStatus.wrong, _stopwatch.elapsed); + _taskNumber++; + solveStatus = null; + setState(() { + setupCurrentTask(); + }); + _stopwatch.reset(); + _stopwatch.start(); + } + + void _onSolveTimeout(bool wideLayout) { + if (solveStatus == null) { + _mistakeCount++; + _totalTime += widget.timePerTask; + solveStatus = VariationStatus.wrong; + if (!solveStatusNotified) { + notifySolveTimeout(wideLayout); + solveStatusNotified = true; + } + setState(() {}); + } + } +} + +class _SideBar extends StatelessWidget { + final String title; + final String taskTitle; + final int taskNumber; + final int taskCount; + final wq.Color color; + final VariationStatus? status; + final UpsolveMode upsolveMode; + final Function()? onShowSolution; + final Function()? onShareTask; + final Function()? onResetTask; + final Function()? onNextTask; + final Function() onCancelExam; + final Function() onPreviousMove; + final Function() onNextMove; + final Function(UpsolveMode) onUpdateUpsolveMode; + final Widget timeDisplay; + + const _SideBar({ + required this.title, + required this.taskTitle, + required this.taskNumber, + required this.taskCount, + required this.color, + this.status, + required this.upsolveMode, + required this.onShowSolution, + required this.onShareTask, + required this.onResetTask, + required this.onNextTask, + required this.onCancelExam, + required this.onPreviousMove, + required this.onNextMove, + required this.onUpdateUpsolveMode, + required this.timeDisplay, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final widgetSize = MediaQuery.sizeOf(context); + final sidebarSize = min( + widgetSize.longestSide - widgetSize.shortestSide, widgetSize.width / 3); + return SizedBox( + width: sidebarSize, + child: Container( + padding: EdgeInsets.all(8), + color: ColorScheme.of(context).surfaceContainer, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + '$taskNumber/$taskCount', + textAlign: TextAlign.center, + )), + IconButton( + icon: Icon(Icons.cancel), + onPressed: () { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: loc.confirm, + content: loc.msgConfirmStopEvent(title), + onYes: onCancelExam, + onNo: () { + Navigator.pop(context); + }, + ), + ); + }, + ), + ], + ), + Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + TurnIcon(color: color), + Text(taskTitle), + ], + ), + Expanded(child: Container()), + (status == null) + ? Center( + child: timeDisplay, + ) + : TaskActionBar( + upsolveMode: upsolveMode, + onShowSolution: onShowSolution, + onShareTask: onShareTask, + onNextTask: onNextTask, + onResetTask: onResetTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, + ), + ], + ), + ), + ); + } +} + +class _ResultDialog extends StatelessWidget { + final Duration totalTime; + final bool passed; + final int mistakeCount; + final int taskCount; + final Function() onExit; + final Function() onRedo; + final Function()? onNext; + + const _ResultDialog( + {required this.totalTime, + required this.passed, + required this.mistakeCount, + required this.taskCount, + required this.onExit, + required this.onRedo, + required this.onNext}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return AlertDialog( + title: passed + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.verified), + Text(loc.trainingPassed) + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [Icon(Icons.cancel), Text(loc.trainingFailed)], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(loc.trainingTotalTime), + trailing: Text(totalTime.toString().substring(2, 7)), + ), + ListTile( + title: Text(loc.trainingAvgTimePerTask), + trailing: Text( + '${(totalTime.inSeconds / taskCount).toStringAsFixed(1)}s'), + ), + ListTile( + title: Text(loc.trainingMistakes), + trailing: Text('$mistakeCount'), + ), + ], + ), + actionsAlignment: MainAxisAlignment.center, + actions: [ + TextButton( + onPressed: onExit, + child: Text(loc.exit), + ), + TextButton( + onPressed: onRedo, + child: Text(loc.taskRedo), + ), + if (onNext != null) + TextButton( + onPressed: onNext, + child: Text(loc.taskNext), + ), + ], + ); + } +} diff --git a/lib/train/grading_exam_page.dart b/lib/train/grading_exam_page.dart index 923d98e2..da5afd75 100644 --- a/lib/train/grading_exam_page.dart +++ b/lib/train/grading_exam_page.dart @@ -1,557 +1,69 @@ import 'dart:math'; -import 'package:extension_type_unions/extension_type_unions.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:wqhub/audio/audio_controller.dart'; -import 'package:wqhub/board/board_annotation.dart'; -import 'package:wqhub/game_client/time_state.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; -import 'package:wqhub/confirm_dialog.dart'; import 'package:wqhub/stats/stats_db.dart'; -import 'package:wqhub/train/grading_exam_task_types.dart'; -import 'package:wqhub/train/response_delay.dart'; -import 'package:wqhub/train/solve_status_notifier.dart'; -import 'package:wqhub/train/task_action_bar.dart'; +import 'package:wqhub/train/exam_page.dart'; +import 'package:wqhub/train/grading_exam_selection_page.dart'; +import 'package:wqhub/train/rank_range.dart'; import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_source/black_to_play_source.dart'; import 'package:wqhub/train/task_source/const_task_source.dart'; -import 'package:wqhub/turn_icon.dart'; -import 'package:wqhub/wq/annotated_game_tree.dart'; -import 'package:wqhub/board/board.dart'; -import 'package:wqhub/board/board_settings.dart'; -import 'package:wqhub/board/coordinate_style.dart'; import 'package:wqhub/train/task_source/task_source.dart'; -import 'package:wqhub/time_display.dart'; -import 'package:wqhub/train/variation_tree.dart'; +import 'package:wqhub/train/task_type.dart'; import 'package:wqhub/wq/rank.dart'; -import 'package:wqhub/wq/wq.dart' as wq; - -class GradingExamPage extends StatefulWidget { - const GradingExamPage( - {super.key, required this.rank, required this.taskSource}); +class GradingExamRouteArguments { final Rank rank; - final TaskSource taskSource; - @override - State createState() => _GradingExamPageState(); + const GradingExamRouteArguments({required this.rank}); } -const _timePerTask = Duration(seconds: 45); - -class _GradingExamPageState extends State - with SolveStatusNotifier { - final _timeDisplayKey = GlobalKey(debugLabel: 'time-display'); - final _stopwatch = Stopwatch(); - VariationTreeIterator? _vtreeIt; - var _gameTree = AnnotatedGameTree(19); - var _turn = wq.Color.black; - var _taskNumber = 1; - VariationStatus? _solveStatus; - bool _solveStatusNotified = false; - var _totalTime = Duration.zero; - var _mistakeCount = 0; - IMapOfSets? _continuationAnnotations; - - @override - void initState() { - super.initState(); - _stopwatch.start(); - _setupCurrentTask(); - } - - @override - void dispose() { - _stopwatch.stop(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; - final borderSize = - 1.5 * (Theme.of(context).textTheme.labelMedium?.fontSize ?? 0); - final border = context.settings.showCoordinates - ? BoardBorderSettings( - size: borderSize, - color: Theme.of(context).colorScheme.surfaceContainerHigh, - rowCoordinates: CoordinateStyle( - labels: CoordinateLabels.numbers, - reverse: true, - ), - columnCoordinates: CoordinateStyle( - labels: CoordinateLabels.alphaNoI, - ), - ) - : null; - - final boardSettings = BoardSettings( - size: widget.taskSource.task.boardSize, - subBoard: SubBoard( - topLeft: widget.taskSource.task.topLeft, - size: widget.taskSource.task.subBoardSize, - ), - theme: context.settings.boardTheme, - edgeLine: context.settings.edgeLine, - border: border, - stoneShadows: context.settings.stoneShadows, - ); - - final board = LayoutBuilder( - builder: (context, constraints) { - final boardSize = constraints.biggest.shortestSide - - 2 * (boardSettings.border?.size ?? 0); - return Board( - size: boardSize, - settings: boardSettings, - onPointClicked: (p) => _onPointClicked(p, wideLayout), - turn: _turn, - stones: _gameTree.stones, - annotations: _continuationAnnotations ?? _gameTree.annotations, - confirmTap: context.settings.confirmMoves, - ); - }, - ); - - final taskTitle = - '[${widget.taskSource.task.rank.toString()}] ${widget.taskSource.task.type.toString()}'; - - final timeDisplay = TimeDisplay( - key: _timeDisplayKey, - timeState: const TimeState( - mainTimeLeft: _timePerTask, - periodTimeLeft: Duration.zero, - periodCount: 0, - ), - warningDuration: const Duration(seconds: 9), - enabled: _solveStatus == null, - tickerEnabled: true, - voiceCountdown: false, - onTimeout: () => _onTimeout(wideLayout), - ); - - if (wideLayout) { - return Scaffold( - body: Center( - child: Row( - children: [ - Expanded(child: Center(child: board)), - VerticalDivider(thickness: 1, width: 8), - _SideBar( - taskTitle: taskTitle, - taskNumber: _taskNumber, - color: widget.taskSource.task.first, - status: _solveStatus, - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, - onCancelExam: () { - context.stats.incrementGradingExamFailCount(widget.rank); - Navigator.popUntil(context, (route) => route.isFirst); - }, - timeDisplay: timeDisplay, - ), - ], - ), - ), - ); - } else { - return Scaffold( - appBar: AppBar( - leading: Center(child: Text('$_taskNumber/10')), - title: Row( - spacing: 4, - children: [ - TurnIcon(color: widget.taskSource.task.first), - Text(taskTitle), - ], - ), - actions: [ - IconButton( - icon: Icon(Icons.cancel), - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Confirm', - content: - 'Are you sure that you want to stop the Grading Exam?', - onYes: () { - context.stats.incrementGradingExamFailCount(widget.rank); - Navigator.popUntil(context, (route) => route.isFirst); - }, - onNo: () { - Navigator.pop(context); - }, - ), - ); - }, - ), - ], - ), - body: Center( - child: board, - ), - bottomNavigationBar: BottomAppBar( - child: (_solveStatus == null) - ? Center(child: timeDisplay) - : TaskActionBar( - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, - ), - ), - ); - } - } - - void _onReplay() { - setState(() { - _setupCurrentTask(); - }); - } - - void _onNext() { - if (_taskNumber == 10) { - if (_mistakeCount <= 2) { - context.stats.incrementGradingExamPassCount(widget.rank); - } else { - context.stats.incrementGradingExamFailCount(widget.rank); - } - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => _ResultDialog( - totalTime: _totalTime, - mistakeCount: _mistakeCount, - onExit: () => Navigator.popUntil(context, (route) => route.isFirst), - onRedo: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: GradingExamPage( - rank: widget.rank, - taskSource: ConstTaskSource( - tasks: TaskRepository() - .read(widget.rank, gradingExamTaskTypes, 10)), - ), - ), - ), - ); - }, - onNext: _mistakeCount <= 2 - ? () { - final nextRank = - Rank.values[min(widget.rank.index + 1, Rank.p10.index)]; - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: GradingExamPage( - rank: nextRank, - taskSource: ConstTaskSource( - tasks: TaskRepository() - .read(nextRank, gradingExamTaskTypes, 10)), - ), - ), - ), - ); - } - : null, - ), - ); - return; - } - widget.taskSource - .next(_solveStatus ?? VariationStatus.wrong, _stopwatch.elapsed); - _taskNumber++; - _solveStatus = null; - setState(() { - _setupCurrentTask(); - }); - _stopwatch.reset(); - _stopwatch.start(); - } +class GradingExamPage extends StatelessWidget { + static const routeName = '/train/grading_exam'; - void _setupCurrentTask() { - _continuationAnnotations = null; - _vtreeIt = - VariationTreeIterator(tree: widget.taskSource.task.variationTree); - _gameTree = AnnotatedGameTree(widget.taskSource.task.boardSize); - for (final entry in widget.taskSource.task.initialStones.entries) { - for (final p in entry.value) { - _gameTree - .moveAnnotated((col: entry.key, p: p), mode: AnnotationMode.none); - } - } - _turn = widget.taskSource.task.first; - _solveStatusNotified = false; - } - - void _onPointClicked(wq.Point p, bool wideLayout) { - if (!(_solveStatusNotified || _turn == widget.taskSource.task.first)) { - return; - } - - if (_gameTree.moveAnnotated((col: _turn, p: p), - mode: AnnotationMode.variation) != - null) { - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } - _continuationAnnotations = null; - final status = _vtreeIt!.move(p); - _turn = _turn.opposite; - if (status != null) { - _setSolveStatus(status, wideLayout); - } else { - switch (context.settings.responseDelay) { - case ResponseDelay.none: - _generateResponseMove(wideLayout); - default: - Future.delayed(context.settings.responseDelay.duration, () { - _generateResponseMove(wideLayout); - }); - } - } - setState(() {/* Update board */}); - } - } - - void _generateResponseMove(bool wideLayout) { - if (_solveStatusNotified) return; - - final resp = _vtreeIt!.genMove(); - _gameTree - .moveAnnotated((col: _turn, p: resp), mode: AnnotationMode.variation); - _turn = _turn.opposite; - final status = _vtreeIt!.move(resp); - if (status != null) { - _setSolveStatus(status, wideLayout); - } - setState(() {/* Update board */}); - } - - void _setSolveStatus(VariationStatus status, bool wideLayout) { - setState(() { - _continuationAnnotations = null; - }); - if (!_solveStatusNotified) { - notifySolveStatus(status, wideLayout); - _solveStatusNotified = true; - } - if (_solveStatus == null) { - final curTask = widget.taskSource.task; - StatsDB().addTaskAttempt(curTask.rank, curTask.type, curTask.id, - status == VariationStatus.correct); - if (status == VariationStatus.correct) { - if (context.settings.sound) AudioController().correct(); - context.stats.incrementTotalPassCount(widget.rank); - } else { - if (context.settings.sound) AudioController().wrong(); - context.stats.incrementTotalFailCount(widget.rank); - _mistakeCount++; - } - _stopwatch.stop(); - _totalTime += _stopwatch.elapsed; - _solveStatus = status; - } - } - - void _onTimeout(bool wideLayout) { - if (_solveStatus == null) { - _mistakeCount++; - _totalTime += _timePerTask; - _solveStatus = VariationStatus.wrong; - if (!_solveStatusNotified) { - notifySolveTimeout(wideLayout); - _solveStatusNotified = true; - } - setState(() {}); - } - } - - _onShowContinuations() { - _continuationAnnotations = IMapOfSets.empty(); - for (final (p, st) - in _vtreeIt?.continuations() ?? <(wq.Point, VariationStatus)>[]) { - _continuationAnnotations = _continuationAnnotations?.add(p, ( - type: AnnotationShape.dot.u21, - color: switch (st) { - VariationStatus.correct => Colors.green, - VariationStatus.wrong => Colors.red, - }, - )); - } - if (_continuationAnnotations?.isNotEmpty ?? false) { - setState(() { - // Update board annotations - }); - } - } - - _onShare() { - final link = widget.taskSource.task.deepLink(); - Clipboard.setData(ClipboardData(text: link)).then((void _) { - if (context.mounted) notifyTaskLinkCopied(); - }); - } -} + static const taskTypes = const ISetConst({ + TaskType.lifeAndDeath, + TaskType.tesuji, + TaskType.capture, + TaskType.captureRace, + }); + static const taskCount = 10; + static const timePerTask = const Duration(seconds: 45); + static const maxMistakes = 2; -class _SideBar extends StatelessWidget { - final String taskTitle; - final int taskNumber; - final wq.Color color; - final VariationStatus? status; - final Function()? onShowSolution; - final Function()? onShare; - final Function()? onReplay; - final Function()? onNext; - final Function() onCancelExam; - final Widget timeDisplay; + final Rank rank; - const _SideBar({ - required this.taskTitle, - required this.taskNumber, - required this.color, - this.status, - required this.onShowSolution, - required this.onShare, - required this.onReplay, - required this.onNext, - required this.onCancelExam, - required this.timeDisplay, - }); + const GradingExamPage({super.key, required this.rank}); @override Widget build(BuildContext context) { - final widgetSize = MediaQuery.sizeOf(context); - final sidebarSize = min( - widgetSize.longestSide - widgetSize.shortestSide, widgetSize.width / 3); - return SizedBox( - width: sidebarSize, - child: Container( - padding: EdgeInsets.all(8), - color: ColorScheme.of(context).surfaceContainer, - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Text( - '$taskNumber/10', - textAlign: TextAlign.center, - )), - IconButton( - icon: Icon(Icons.cancel), - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Confirm', - content: - 'Are you sure that you want to stop the Grading Exam?', - onYes: onCancelExam, - onNo: () { - Navigator.pop(context); - }, - ), - ); - }, - ), - ], - ), - Row( - spacing: 8, - mainAxisSize: MainAxisSize.min, - children: [ - TurnIcon(color: color), - Text(taskTitle), - ], - ), - Expanded(child: Container()), - (status == null) - ? Center( - child: timeDisplay, - ) - : TaskActionBar( - onShowSolution: onShowSolution, - onShare: onShare, - onNext: onNext, - onReplay: onReplay, - ), - ], - ), - ), + final loc = AppLocalizations.of(context)!; + final nextRank = Rank.values[min(rank.index + 1, Rank.p10.index)]; + return ExamPage( + title: loc.gradingExam, + examEvent: ExamEvent(type: ExamType.grading), + rankRange: RankRange.single(rank), + taskCount: taskCount, + timePerTask: timePerTask, + maxMistakes: maxMistakes, + createTaskSource: createTaskSource, + onPass: () => context.stats.incrementGradingExamPassCount(rank), + onFail: () => context.stats.incrementGradingExamFailCount(rank), + baseRoute: routeName, + exitRoute: GradingExamSelectionPage.routeName, + redoRouteArguments: GradingExamRouteArguments(rank: rank), + nextRouteArguments: GradingExamRouteArguments(rank: nextRank), ); } -} - -class _ResultDialog extends StatelessWidget { - final Duration totalTime; - final int mistakeCount; - final Function() onExit; - final Function() onRedo; - final Function()? onNext; - const _ResultDialog( - {required this.totalTime, - required this.mistakeCount, - required this.onExit, - required this.onRedo, - required this.onNext}); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: (mistakeCount <= 2) - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.verified), const Text('Passed')], - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.cancel), const Text('Failed')], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: const Text('Total time'), - trailing: Text(totalTime.toString().substring(2, 7)), - ), - ListTile( - title: const Text('Avg time per task'), - trailing: Text('${(totalTime.inSeconds / 10).toStringAsFixed(1)}s'), - ), - ListTile( - title: const Text('Mistakes'), - trailing: Text('$mistakeCount'), - ), - ], - ), - actionsAlignment: MainAxisAlignment.center, - actions: [ - TextButton( - onPressed: onExit, - child: const Text('Exit'), - ), - TextButton( - onPressed: onRedo, - child: const Text('Redo'), - ), - if (onNext != null) - TextButton( - onPressed: onNext, - child: const Text('Next'), - ), - ], + TaskSource createTaskSource(BuildContext context) { + return BlackToPlaySource( + source: ConstTaskSource( + tasks: TaskRepository().readByTypes(rank, taskTypes, taskCount)), + blackToPlay: context.settings.alwaysBlackToPlay, ); } } diff --git a/lib/train/grading_exam_selection_page.dart b/lib/train/grading_exam_selection_page.dart index 8390ca5c..7bbbb6b9 100644 --- a/lib/train/grading_exam_selection_page.dart +++ b/lib/train/grading_exam_selection_page.dart @@ -2,19 +2,20 @@ import 'dart:math'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; +import 'package:wqhub/help/grading_exam_help_dialog.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/pop_and_window_class_aware_state.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/train/exam_rank_card.dart'; import 'package:wqhub/train/grading_exam_page.dart'; import 'package:wqhub/train/grading_exam_ranks.dart'; -import 'package:wqhub/train/grading_exam_task_types.dart'; import 'package:wqhub/train/rank_range.dart'; -import 'package:wqhub/train/task_repository.dart'; -import 'package:wqhub/train/task_source/black_to_play_source.dart'; -import 'package:wqhub/train/task_source/const_task_source.dart'; import 'package:wqhub/window_class_aware_state.dart'; import 'package:wqhub/wq/rank.dart'; class GradingExamSelectionPage extends StatefulWidget { + static const routeName = '/train/grading_exam_selection'; + const GradingExamSelectionPage({super.key}); @override @@ -23,13 +24,27 @@ class GradingExamSelectionPage extends StatefulWidget { } class _GradingExamSelectionPageState - extends WindowClassAwareState { + extends PopAndWindowClassAwareState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.settings.showGradingExamHelp) { + showDialog( + context: context, + builder: (context) => GradingExamHelpDialog(), + ); + } + }); + } + @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final stats = loadStats(); return Scaffold( appBar: AppBar( - title: Text('Grading exam'), + title: Text(loc.gradingExam), ), body: Center( child: ConstrainedBox( @@ -57,21 +72,10 @@ class _GradingExamSelectionPageState 0) > 0), onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: GradingExamPage( - rank: rank, - taskSource: BlackToPlaySource( - source: ConstTaskSource( - tasks: TaskRepository() - .read(rank, gradingExamTaskTypes, 10)), - blackToPlay: context.settings.alwaysBlackToPlay, - )), - ), - ), + GradingExamPage.routeName, + arguments: GradingExamRouteArguments(rank: rank), ); }, ), diff --git a/lib/train/grading_exam_task_types.dart b/lib/train/grading_exam_task_types.dart deleted file mode 100644 index b7db6ad4..00000000 --- a/lib/train/grading_exam_task_types.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:wqhub/train/task_type.dart'; - -final gradingExamTaskTypes = const ISetConst({ - TaskType.lifeAndDeath, - TaskType.tesuji, - TaskType.capture, - TaskType.captureRace, -}); diff --git a/lib/train/my_mistakes_page.dart b/lib/train/my_mistakes_page.dart index 95cd3e35..facfee9a 100644 --- a/lib/train/my_mistakes_page.dart +++ b/lib/train/my_mistakes_page.dart @@ -1,11 +1,14 @@ import 'dart:collection'; import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/stats/stats_db.dart'; import 'package:wqhub/train/task_preview_tile.dart'; import 'package:wqhub/window_class_aware_state.dart'; class MyMistakesPage extends StatefulWidget { + static const routeName = '/train/my_mistakes'; + const MyMistakesPage({super.key}); @override @@ -24,14 +27,15 @@ class _MyMistakesPageState extends WindowClassAwareState { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( - title: const Text('My mistakes'), + title: Text(loc.myMistakes), actions: [ DropdownButton<_SortMode>( icon: Icon(Icons.sort), value: selectedSortMode, - items: _SortMode.entries, + items: _SortMode.entries(loc), onChanged: (_SortMode? sortLabel) { if (sortLabel != null && sortLabel != selectedSortMode) { setState(() { @@ -81,21 +85,23 @@ class _MyMistakesPageState extends WindowClassAwareState { typedef _SortModeEntry = DropdownMenuItem<_SortMode>; enum _SortMode { - recent('Recent'), - difficult('Difficult'); - - const _SortMode(this.label); - final String label; + recent, + difficult; - static final List<_SortModeEntry> entries = + static List<_SortModeEntry> entries(AppLocalizations loc) => UnmodifiableListView<_SortModeEntry>( - values.map<_SortModeEntry>( - (l) => _SortModeEntry( - value: l, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: Text(l.label), - )), - ), - ); + values.map<_SortModeEntry>( + (l) => _SortModeEntry( + value: l, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Text(l.toLocalizedString(loc)), + )), + ), + ); + + String toLocalizedString(AppLocalizations loc) => switch (this) { + _SortMode.recent => loc.sortModeRecent, + _SortMode.difficult => loc.sortModeDifficult, + }; } diff --git a/lib/train/rank_range.dart b/lib/train/rank_range.dart index f473cb54..c2c66eb1 100644 --- a/lib/train/rank_range.dart +++ b/lib/train/rank_range.dart @@ -1,5 +1,7 @@ +import 'package:flutter/foundation.dart'; import 'package:wqhub/wq/rank.dart'; +@immutable class RankRange { final Rank from; final Rank to; @@ -12,6 +14,19 @@ class RankRange { bool isSingle() => from == to; + bool isInside(RankRange that) => that.from <= from && to <= that.to; + + @override + int get hashCode => Object.hash(from, to); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + + return other is RankRange && other.from == from && other.to == to; + } + @override String toString() => from == to ? from.toString() : '${from.toString()}-${to.toString()}'; diff --git a/lib/train/ranked_mode_page.dart b/lib/train/ranked_mode_page.dart index 6572bb55..33e98c05 100644 --- a/lib/train/ranked_mode_page.dart +++ b/lib/train/ranked_mode_page.dart @@ -1,18 +1,14 @@ import 'dart:math'; -import 'package:extension_type_unions/extension_type_unions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:wqhub/audio/audio_controller.dart'; -import 'package:wqhub/board/board_annotation.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/stats/stats_db.dart'; -import 'package:wqhub/train/response_delay.dart'; -import 'package:wqhub/train/solve_status_notifier.dart'; import 'package:wqhub/train/task_action_bar.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_solving_state_mixin.dart'; +import 'package:wqhub/train/upsolve_mode.dart'; import 'package:wqhub/turn_icon.dart'; -import 'package:wqhub/wq/annotated_game_tree.dart'; import 'package:wqhub/board/board.dart'; import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/coordinate_style.dart'; @@ -21,7 +17,15 @@ import 'package:wqhub/train/variation_tree.dart'; import 'package:wqhub/wq/rank.dart'; import 'package:wqhub/wq/wq.dart' as wq; +class RankedModeRouteArguments { + final TaskSource taskSource; + + RankedModeRouteArguments({required this.taskSource}); +} + class RankedModePage extends StatefulWidget { + static const routeName = '/train/ranked_mode'; + const RankedModePage({super.key, required this.taskSource}); final TaskSource taskSource; @@ -31,21 +35,14 @@ class RankedModePage extends StatefulWidget { } class _RankedModePageState extends State - with SolveStatusNotifier { + with TaskSolvingStateMixin { final _stopwatch = Stopwatch(); - VariationTreeIterator? _vtreeIt; - var _gameTree = AnnotatedGameTree(19); - var _turn = wq.Color.black; var _taskNumber = 1; - VariationStatus? _solveStatus; - bool _solveStatusNotified = false; - IMapOfSets? _continuationAnnotations; @override void initState() { super.initState(); _stopwatch.start(); - _setupCurrentTask(); } @override @@ -56,6 +53,7 @@ class _RankedModePageState extends State @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; final borderSize = 1.5 * (Theme.of(context).textTheme.labelMedium?.fontSize ?? 0); @@ -92,17 +90,17 @@ class _RankedModePageState extends State return Board( size: boardSize, settings: boardSettings, - onPointClicked: (p) => _onPointClicked(p, wideLayout), - turn: _turn, - stones: _gameTree.stones, - annotations: _continuationAnnotations ?? _gameTree.annotations, + onPointClicked: (p) => onMove(p, wideLayout), + turn: turn, + stones: gameTree.stones, + annotations: continuationAnnotations ?? gameTree.annotations, confirmTap: context.settings.confirmMoves, ); }, ); final taskTitle = - '[${widget.taskSource.task.rank.toString()}] ${widget.taskSource.task.type.toString()}'; + '[${widget.taskSource.task.rank.toString()}] ${widget.taskSource.task.type.toLocalizedString(loc)}'; final rankDisplay = Text(Rank.decimalString(widget.taskSource.rank), style: TextTheme.of(context).titleLarge); @@ -118,11 +116,15 @@ class _RankedModePageState extends State taskTitle: taskTitle, taskNumber: _taskNumber, color: widget.taskSource.task.first, - status: _solveStatus, - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, + status: solveStatus, + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onResetTask: onResetTask, + onNextTask: onNextTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, timeDisplay: rankDisplay, ), ], @@ -153,147 +155,51 @@ class _RankedModePageState extends State child: board, ), bottomNavigationBar: BottomAppBar( - child: (_solveStatus == null) + height: upsolveMode == UpsolveMode.auto ? 80.0 : 160.0, + child: (solveStatus == null) ? Center(child: rankDisplay) : TaskActionBar( - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onResetTask: onResetTask, + onNextTask: onNextTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, ), ), ); } } - void _onReplay() { - setState(() { - _setupCurrentTask(); - }); + @override + Task get currentTask => widget.taskSource.task; + + @override + void onSolveStatus(VariationStatus status) { + _stopwatch.stop(); + StatsDB().addTaskAttempt(currentTask.rank, currentTask.type, currentTask.id, + status == VariationStatus.correct); + if (status == VariationStatus.correct) { + context.stats.incrementTotalPassCount(currentTask.rank); + } else { + context.stats.incrementTotalFailCount(currentTask.rank); + } } - void _onNext() { + void onNextTask() { widget.taskSource.next( - _solveStatus ?? VariationStatus.wrong, _stopwatch.elapsed, + solveStatus ?? VariationStatus.wrong, _stopwatch.elapsed, onRankChanged: context.stats.updateRankedModeRank); _taskNumber++; - _solveStatus = null; + solveStatus = null; setState(() { - _setupCurrentTask(); + setupCurrentTask(); }); _stopwatch.reset(); _stopwatch.start(); } - - void _setupCurrentTask() { - _continuationAnnotations = null; - _vtreeIt = - VariationTreeIterator(tree: widget.taskSource.task.variationTree); - _gameTree = AnnotatedGameTree(widget.taskSource.task.boardSize); - for (final entry in widget.taskSource.task.initialStones.entries) { - for (final p in entry.value) { - _gameTree - .moveAnnotated((col: entry.key, p: p), mode: AnnotationMode.none); - } - } - _turn = widget.taskSource.task.first; - _solveStatusNotified = false; - } - - void _onPointClicked(wq.Point p, bool wideLayout) { - if (!(_solveStatusNotified || _turn == widget.taskSource.task.first)) { - return; - } - - if (_gameTree.moveAnnotated((col: _turn, p: p), - mode: AnnotationMode.variation) != - null) { - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } - _continuationAnnotations = null; - final status = _vtreeIt!.move(p); - _turn = _turn.opposite; - if (status != null) { - _setSolveStatus(status, wideLayout); - } else { - switch (context.settings.responseDelay) { - case ResponseDelay.none: - _generateResponseMove(wideLayout); - default: - Future.delayed(context.settings.responseDelay.duration, () { - _generateResponseMove(wideLayout); - }); - } - } - setState(() {/* Update board */}); - } - } - - void _generateResponseMove(bool wideLayout) { - if (_solveStatusNotified) return; - - final resp = _vtreeIt!.genMove(); - _gameTree - .moveAnnotated((col: _turn, p: resp), mode: AnnotationMode.variation); - _turn = _turn.opposite; - final status = _vtreeIt!.move(resp); - if (status != null) { - _setSolveStatus(status, wideLayout); - } - setState(() {/* Update board */}); - } - - void _setSolveStatus(VariationStatus status, bool wideLayout) { - setState(() { - _continuationAnnotations = null; - }); - if (!_solveStatusNotified) { - notifySolveStatus(status, wideLayout); - _solveStatusNotified = true; - } - if (_solveStatus == null) { - _stopwatch.stop(); - _solveStatus = status; - - final curTask = widget.taskSource.task; - StatsDB().addTaskAttempt(curTask.rank, curTask.type, curTask.id, - status == VariationStatus.correct); - if (status == VariationStatus.correct) { - if (context.settings.sound) AudioController().correct(); - context.stats.incrementTotalPassCount(curTask.rank); - } else { - if (context.settings.sound) AudioController().wrong(); - context.stats.incrementTotalFailCount(curTask.rank); - } - } - } - - _onShowContinuations() { - _continuationAnnotations = IMapOfSets.empty(); - for (final (p, st) - in _vtreeIt?.continuations() ?? <(wq.Point, VariationStatus)>[]) { - _continuationAnnotations = _continuationAnnotations?.add(p, ( - type: AnnotationShape.dot.u21, - color: switch (st) { - VariationStatus.correct => Colors.green, - VariationStatus.wrong => Colors.red, - }, - )); - } - if (_continuationAnnotations?.isNotEmpty ?? false) { - setState(() { - // Update board annotations - }); - } - } - - _onShare() { - final link = widget.taskSource.task.deepLink(); - Clipboard.setData(ClipboardData(text: link)).then((void _) { - if (context.mounted) notifyTaskLinkCopied(); - }); - } } class _SideBar extends StatelessWidget { @@ -301,10 +207,14 @@ class _SideBar extends StatelessWidget { final int taskNumber; final wq.Color color; final VariationStatus? status; + final UpsolveMode upsolveMode; final Function()? onShowSolution; - final Function()? onShare; - final Function()? onReplay; - final Function()? onNext; + final Function()? onShareTask; + final Function()? onResetTask; + final Function()? onNextTask; + final Function() onPreviousMove; + final Function() onNextMove; + final Function(UpsolveMode) onUpdateUpsolveMode; final Widget timeDisplay; const _SideBar({ @@ -312,10 +222,14 @@ class _SideBar extends StatelessWidget { required this.taskNumber, required this.color, this.status, + required this.upsolveMode, required this.onShowSolution, - required this.onShare, - required this.onReplay, - required this.onNext, + required this.onShareTask, + required this.onResetTask, + required this.onNextTask, + required this.onPreviousMove, + required this.onNextMove, + required this.onUpdateUpsolveMode, required this.timeDisplay, }); @@ -360,10 +274,14 @@ class _SideBar extends StatelessWidget { child: timeDisplay, ) : TaskActionBar( + upsolveMode: upsolveMode, onShowSolution: onShowSolution, - onShare: onShare, - onNext: onNext, - onReplay: onReplay, + onShareTask: onShareTask, + onNextTask: onNextTask, + onResetTask: onResetTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, ), ], ), diff --git a/lib/train/response_delay.dart b/lib/train/response_delay.dart index 280c1b88..84e8c7ac 100644 --- a/lib/train/response_delay.dart +++ b/lib/train/response_delay.dart @@ -1,3 +1,5 @@ +import 'package:wqhub/l10n/app_localizations.dart'; + enum ResponseDelay { none, short, @@ -10,4 +12,11 @@ enum ResponseDelay { medium => Duration(milliseconds: 200), long => Duration(milliseconds: 400), }; + + String toLocalizedString(AppLocalizations loc) => switch (this) { + ResponseDelay.none => loc.responseDelayNone, + ResponseDelay.short => loc.responseDelayShort, + ResponseDelay.medium => loc.responseDelayMedium, + ResponseDelay.long => loc.responseDelayLong, + }; } diff --git a/lib/train/single_task_page.dart b/lib/train/single_task_page.dart index b0b4fc97..a6ea5f37 100644 --- a/lib/train/single_task_page.dart +++ b/lib/train/single_task_page.dart @@ -1,25 +1,28 @@ import 'dart:math'; -import 'package:extension_type_unions/extension_type_unions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:wqhub/audio/audio_controller.dart'; -import 'package:wqhub/board/board_annotation.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; -import 'package:wqhub/train/response_delay.dart'; -import 'package:wqhub/train/solve_status_notifier.dart'; import 'package:wqhub/train/task_action_bar.dart'; import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_solving_state_mixin.dart'; +import 'package:wqhub/train/upsolve_mode.dart'; import 'package:wqhub/turn_icon.dart'; -import 'package:wqhub/wq/annotated_game_tree.dart'; import 'package:wqhub/board/board.dart'; import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/coordinate_style.dart'; import 'package:wqhub/train/variation_tree.dart'; import 'package:wqhub/wq/wq.dart' as wq; +class SingleTaskRouteArguments { + final Task task; + + const SingleTaskRouteArguments({required this.task}); +} + class SingleTaskPage extends StatefulWidget { + static const routeName = '/task'; + const SingleTaskPage({super.key, required this.task}); final Task task; @@ -29,22 +32,10 @@ class SingleTaskPage extends StatefulWidget { } class _SingleTaskPageState extends State - with SolveStatusNotifier { - VariationTreeIterator? _vtreeIt; - var _gameTree = AnnotatedGameTree(19); - var _turn = wq.Color.black; - VariationStatus? _solveStatus; - bool _solveStatusNotified = false; - IMapOfSets? _continuationAnnotations; - - @override - void initState() { - super.initState(); - _setupCurrentTask(); - } - + with TaskSolvingStateMixin { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; final borderSize = 1.5 * (Theme.of(context).textTheme.labelMedium?.fontSize ?? 0); @@ -81,17 +72,17 @@ class _SingleTaskPageState extends State return Board( size: boardSize, settings: boardSettings, - onPointClicked: (p) => _onPointClicked(p, wideLayout), - turn: _turn, - stones: _gameTree.stones, - annotations: _continuationAnnotations ?? _gameTree.annotations, + onPointClicked: (p) => onMove(p, wideLayout), + turn: turn, + stones: gameTree.stones, + annotations: continuationAnnotations ?? gameTree.annotations, confirmTap: context.settings.confirmMoves, ); }, ); final taskTitle = - '[${widget.task.rank.toString()}] ${widget.task.type.toString()}'; + '[${widget.task.rank.toString()}] ${widget.task.type.toLocalizedString(loc)}'; if (wideLayout) { return Scaffold( @@ -103,10 +94,13 @@ class _SingleTaskPageState extends State _SideBar( taskTitle: taskTitle, color: widget.task.first, - status: _solveStatus, - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onResetTask: onResetTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, timeDisplay: Text(widget.task.deepLink()), ), ], @@ -128,143 +122,50 @@ class _SingleTaskPageState extends State child: board, ), bottomNavigationBar: BottomAppBar( - child: (_solveStatus == null) - ? Center(child: Text(widget.task.deepLink())) - : TaskActionBar( - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - ), + height: upsolveMode == UpsolveMode.auto ? 80.0 : 160.0, + child: TaskActionBar( + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onResetTask: onResetTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, + ), ), ); } } - void _onReplay() { - setState(() { - _setupCurrentTask(); - }); - } - - void _setupCurrentTask() { - _continuationAnnotations = null; - _vtreeIt = VariationTreeIterator(tree: widget.task.variationTree); - _gameTree = AnnotatedGameTree(widget.task.boardSize); - for (final entry in widget.task.initialStones.entries) { - for (final p in entry.value) { - _gameTree - .moveAnnotated((col: entry.key, p: p), mode: AnnotationMode.none); - } - } - _turn = widget.task.first; - _solveStatusNotified = false; - } - - void _onPointClicked(wq.Point p, bool wideLayout) { - if (!(_solveStatusNotified || _turn == widget.task.first)) { - return; - } - - if (_gameTree.moveAnnotated((col: _turn, p: p), - mode: AnnotationMode.variation) != - null) { - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } - _continuationAnnotations = null; - final status = _vtreeIt!.move(p); - _turn = _turn.opposite; - if (status != null) { - _setSolveStatus(status, wideLayout); - } else { - switch (context.settings.responseDelay) { - case ResponseDelay.none: - _generateResponseMove(wideLayout); - default: - Future.delayed(context.settings.responseDelay.duration, () { - _generateResponseMove(wideLayout); - }); - } - } - setState(() {/* Update board */}); - } - } - - void _generateResponseMove(bool wideLayout) { - if (_solveStatusNotified) return; - - final resp = _vtreeIt!.genMove(); - _gameTree - .moveAnnotated((col: _turn, p: resp), mode: AnnotationMode.variation); - _turn = _turn.opposite; - final status = _vtreeIt!.move(resp); - if (status != null) { - _setSolveStatus(status, wideLayout); - } - setState(() {/* Update board */}); - } - - void _setSolveStatus(VariationStatus status, bool wideLayout) { - setState(() { - _continuationAnnotations = null; - }); - if (!_solveStatusNotified) { - notifySolveStatus(status, wideLayout); - _solveStatusNotified = true; - } - if (_solveStatus == null) { - _solveStatus = status; - if (status == VariationStatus.correct) { - if (context.settings.sound) AudioController().correct(); - } else { - if (context.settings.sound) AudioController().wrong(); - } - } - } - - _onShowContinuations() { - _continuationAnnotations = IMapOfSets.empty(); - for (final (p, st) - in _vtreeIt?.continuations() ?? <(wq.Point, VariationStatus)>[]) { - _continuationAnnotations = _continuationAnnotations?.add(p, ( - type: AnnotationShape.dot.u21, - color: switch (st) { - VariationStatus.correct => Colors.green, - VariationStatus.wrong => Colors.red, - }, - )); - } - if (_continuationAnnotations?.isNotEmpty ?? false) { - setState(() { - // Update board annotations - }); - } - } + @override + Task get currentTask => widget.task; - _onShare() { - final link = widget.task.deepLink(); - Clipboard.setData(ClipboardData(text: link)).then((void _) { - if (context.mounted) notifyTaskLinkCopied(); - }); - } + @override + void onSolveStatus(VariationStatus status) {} } class _SideBar extends StatelessWidget { final String taskTitle; final wq.Color color; - final VariationStatus? status; + final UpsolveMode upsolveMode; final Function()? onShowSolution; - final Function()? onShare; - final Function()? onReplay; + final Function()? onShareTask; + final Function()? onResetTask; + final Function() onPreviousMove; + final Function() onNextMove; + final Function(UpsolveMode) onUpdateUpsolveMode; final Widget timeDisplay; const _SideBar({ required this.taskTitle, required this.color, - this.status, + required this.upsolveMode, required this.onShowSolution, - required this.onShare, - required this.onReplay, + required this.onShareTask, + required this.onResetTask, + required this.onPreviousMove, + required this.onNextMove, + required this.onUpdateUpsolveMode, required this.timeDisplay, }); @@ -297,15 +198,15 @@ class _SideBar extends StatelessWidget { ], ), Expanded(child: Container()), - (status == null) - ? Center( - child: timeDisplay, - ) - : TaskActionBar( - onShowSolution: onShowSolution, - onShare: onShare, - onReplay: onReplay, - ), + TaskActionBar( + upsolveMode: upsolveMode, + onShowSolution: onShowSolution, + onShareTask: onShareTask, + onResetTask: onResetTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, + ), ], ), ), diff --git a/lib/train/solve_status_notifier.dart b/lib/train/solve_status_notifier.dart deleted file mode 100644 index 803b0cfc..00000000 --- a/lib/train/solve_status_notifier.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:wqhub/train/variation_tree.dart'; - -mixin SolveStatusNotifier { - BuildContext get context; - - void notifySolveStatus(VariationStatus status, bool wideLayout) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 16, - children: [ - Icon(status == VariationStatus.correct - ? Icons.check_circle - : Icons.sentiment_very_dissatisfied), - Text(status.toString(), style: TextTheme.of(context).titleMedium), - ], - ), - behavior: wideLayout ? SnackBarBehavior.floating : SnackBarBehavior.fixed, - margin: _snackBarMargin(wideLayout), - duration: Duration(seconds: 1), - backgroundColor: - status == VariationStatus.correct ? Colors.green : Colors.red, - showCloseIcon: true, - )); - } - - void notifySolveTimeout(bool wideLayout) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 16, - children: [ - Icon(Icons.timer), - Text('Timeout', style: TextTheme.of(context).titleMedium), - ], - ), - behavior: wideLayout ? SnackBarBehavior.floating : SnackBarBehavior.fixed, - margin: _snackBarMargin(wideLayout), - duration: Duration(seconds: 1), - backgroundColor: Colors.red, - showCloseIcon: true, - )); - } - - void notifyTaskLinkCopied() { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Task link copied.'))); - } - - EdgeInsetsGeometry? _snackBarMargin(bool wideLayout) { - if (wideLayout) { - final widgetSize = MediaQuery.sizeOf(context); - final sidebarSize = min(widgetSize.longestSide - widgetSize.shortestSide, - widgetSize.width / 3); - return EdgeInsets.only(right: sidebarSize); - } else { - return null; - } - } -} diff --git a/lib/train/subtag_rank_selection_page.dart b/lib/train/subtag_rank_selection_page.dart index 9aaa8a77..b5982e22 100644 --- a/lib/train/subtag_rank_selection_page.dart +++ b/lib/train/subtag_rank_selection_page.dart @@ -1,16 +1,23 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/pop_and_window_class_aware_state.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/train/exam_rank_card.dart'; import 'package:wqhub/train/rank_range.dart'; import 'package:wqhub/train/tag_exam_page.dart'; -import 'package:wqhub/train/task_repository.dart'; -import 'package:wqhub/train/task_source/black_to_play_source.dart'; -import 'package:wqhub/train/task_source/const_task_source.dart'; import 'package:wqhub/train/task_tag.dart'; import 'package:wqhub/window_class_aware_state.dart'; +class SubtagRankSelectionRouteArguments { + final TaskTag subtag; + + const SubtagRankSelectionRouteArguments({required this.subtag}); +} + class SubtagRankSelectionPage extends StatefulWidget { + static const routeName = '/train/subtag_rank_selection'; + final TaskTag subtag; const SubtagRankSelectionPage({super.key, required this.subtag}); @@ -21,14 +28,15 @@ class SubtagRankSelectionPage extends StatefulWidget { } class _SubtagRankSelectionPageState - extends WindowClassAwareState { + extends PopAndWindowClassAwareState { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final rankRanges = widget.subtag.ranks(); final stats = loadStats(rankRanges); return Scaffold( appBar: AppBar( - title: Text(widget.subtag.toString()), + title: Text(widget.subtag.toLocalizedString(loc)), ), body: Center( child: ConstrainedBox( @@ -52,21 +60,12 @@ class _SubtagRankSelectionPageState failCount: stats[rankRange]?.fail ?? 0, isActive: i == 0 || (stats[rankRanges[i - 1]]?.pass ?? 0) > 0, onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: TagExamPage( - tag: widget.subtag, - rankRange: rankRange, - taskSource: BlackToPlaySource( - source: ConstTaskSource( - tasks: TaskRepository().readByTag( - widget.subtag, rankRange, 10)), - blackToPlay: context.settings.alwaysBlackToPlay, - )), - ), + TagExamPage.routeName, + arguments: TagExamRouteArguments( + tag: widget.subtag, + rankRange: rankRange, ), ); }, diff --git a/lib/train/subtags_page.dart b/lib/train/subtags_page.dart index fdae2813..eb933da9 100644 --- a/lib/train/subtags_page.dart +++ b/lib/train/subtags_page.dart @@ -1,18 +1,28 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/train/subtag_rank_selection_page.dart'; import 'package:wqhub/train/tag_completion_rate.dart'; import 'package:wqhub/train/task_tag.dart'; +class SubtagsRouteArguments { + final TaskTag tag; + + const SubtagsRouteArguments({required this.tag}); +} + class SubtagsPage extends StatelessWidget { + static const routeName = '/train/subtags'; + final TaskTag tag; const SubtagsPage({super.key, required this.tag}); @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( - title: Text(tag.toString()), + title: Text(tag.toLocalizedString(loc)), ), body: Center( child: ListView( @@ -20,15 +30,14 @@ class SubtagsPage extends StatelessWidget { for (final subtag in tag.subtags()) if (subtag.ranks().isNotEmpty) ListTile( - title: Text(subtag.toString()), + title: Text(subtag.toLocalizedString(loc)), trailing: TagCompletionRate(tag: subtag), onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => - SubtagRankSelectionPage(subtag: subtag), - ), + SubtagRankSelectionPage.routeName, + arguments: + SubtagRankSelectionRouteArguments(subtag: subtag), ); }, ) diff --git a/lib/train/tag_completion_rate.dart b/lib/train/tag_completion_rate.dart index 60ca30b9..297d0ece 100644 --- a/lib/train/tag_completion_rate.dart +++ b/lib/train/tag_completion_rate.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/pop_aware_state.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/train/circular_percent_text.dart'; +import 'package:wqhub/train/rank_range.dart'; import 'package:wqhub/train/task_tag.dart'; +import 'package:wqhub/wq/rank.dart'; class TagCompletionRate extends StatefulWidget { final TaskTag tag; @@ -12,46 +15,88 @@ class TagCompletionRate extends StatefulWidget { State createState() => _TagCompletionRateState(); } -class _TagCompletionRateState extends State { - late final Future completionRateFut; +class _TagCompletionRateState extends PopAwareState { + Future<_TagCompletionData>? completionRateFut; @override void initState() { - completionRateFut = Future(() { - final (total, passed) = compute(context, widget.tag); - return (100 * passed / total).floor(); - }); + _updateFut(); super.initState(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateFut(); + } + @override Widget build(BuildContext context) { return FutureBuilder( future: completionRateFut, builder: (context, snapshot) { if (snapshot.hasData) { - final completionRate = snapshot.data ?? 0; - return CircularPercentText(value: completionRate); + final data = snapshot.data!; + return Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.all(4.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: ColorScheme.of(context).secondaryContainer, + ), + child: Text(data.rank.toString()), + ), + CircularPercentText(value: data.rate), + ], + ); } return CircularProgressIndicator(); }, ); } - (int, int) compute(BuildContext context, TaskTag tag) { + (int, int, RankRange) compute(BuildContext context, TaskTag tag) { var total = 0; var passed = 0; + var minRank = Rank.p10; + var maxRank = Rank.k30; final rankRanges = tag.ranks(); for (final rankRange in rankRanges) { final pass = context.stats.getTagExamPassCount(tag, rankRange); total++; - if (pass > 0) passed++; + if (pass > 0) { + passed++; + if (rankRange.to > maxRank) maxRank = rankRange.to; + } else if (rankRange.from < minRank) minRank = rankRange.from; } for (final subtag in tag.subtags()) { - final (t, p) = compute(context, subtag); + final (t, p, rr) = compute(context, subtag); total += t; passed += p; + if (rr.from < minRank) minRank = rr.from; + if (rr.to > maxRank) maxRank = rr.to; } - return (total, passed); + if (minRank == Rank.p10 && maxRank != Rank.k30) minRank = maxRank; + return (total, passed, RankRange.single(minRank)); + } + + _updateFut() { + completionRateFut = Future(() { + final (total, passed, curRank) = compute(context, widget.tag); + return _TagCompletionData( + rate: (100 * passed / total).floor(), + rank: curRank, + ); + }); } } + +class _TagCompletionData { + final int rate; + final RankRange rank; + + const _TagCompletionData({required this.rate, required this.rank}); +} diff --git a/lib/train/tag_exam_page.dart b/lib/train/tag_exam_page.dart index 1a3caafd..20a82a5f 100644 --- a/lib/train/tag_exam_page.dart +++ b/lib/train/tag_exam_page.dart @@ -1,544 +1,67 @@ -import 'dart:math'; - -import 'package:extension_type_unions/extension_type_unions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:wqhub/audio/audio_controller.dart'; -import 'package:wqhub/board/board_annotation.dart'; -import 'package:wqhub/game_client/time_state.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; -import 'package:wqhub/confirm_dialog.dart'; import 'package:wqhub/stats/stats_db.dart'; +import 'package:wqhub/train/exam_page.dart'; import 'package:wqhub/train/rank_range.dart'; -import 'package:wqhub/train/response_delay.dart'; -import 'package:wqhub/train/solve_status_notifier.dart'; -import 'package:wqhub/train/task_action_bar.dart'; +import 'package:wqhub/train/subtag_rank_selection_page.dart'; import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_source/black_to_play_source.dart'; import 'package:wqhub/train/task_source/const_task_source.dart'; -import 'package:wqhub/train/task_tag.dart'; -import 'package:wqhub/turn_icon.dart'; -import 'package:wqhub/wq/annotated_game_tree.dart'; -import 'package:wqhub/board/board.dart'; -import 'package:wqhub/board/board_settings.dart'; -import 'package:wqhub/board/coordinate_style.dart'; import 'package:wqhub/train/task_source/task_source.dart'; -import 'package:wqhub/time_display.dart'; -import 'package:wqhub/train/variation_tree.dart'; -import 'package:wqhub/wq/wq.dart' as wq; - -class TagExamPage extends StatefulWidget { - const TagExamPage( - {super.key, - required this.tag, - required this.rankRange, - required this.taskSource}); +import 'package:wqhub/train/task_tag.dart'; +class TagExamRouteArguments { final TaskTag tag; final RankRange rankRange; - final TaskSource taskSource; - @override - State createState() => _TagExamPageState(); + const TagExamRouteArguments({required this.tag, required this.rankRange}); } -const _timePerTask = Duration(seconds: 300); - -class _TagExamPageState extends State with SolveStatusNotifier { - final _timeDisplayKey = GlobalKey(debugLabel: 'time-display'); - final _stopwatch = Stopwatch(); - VariationTreeIterator? _vtreeIt; - var _gameTree = AnnotatedGameTree(19); - var _turn = wq.Color.black; - var _taskNumber = 1; - VariationStatus? _solveStatus; - bool _solveStatusNotified = false; - var _totalTime = Duration.zero; - var _mistakeCount = 0; - IMapOfSets? _continuationAnnotations; - - @override - void initState() { - super.initState(); - _stopwatch.start(); - _setupCurrentTask(); - } - - @override - void dispose() { - _stopwatch.stop(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; - final borderSize = - 1.5 * (Theme.of(context).textTheme.labelMedium?.fontSize ?? 0); - final border = context.settings.showCoordinates - ? BoardBorderSettings( - size: borderSize, - color: Theme.of(context).colorScheme.surfaceContainerHigh, - rowCoordinates: CoordinateStyle( - labels: CoordinateLabels.numbers, - reverse: true, - ), - columnCoordinates: CoordinateStyle( - labels: CoordinateLabels.alphaNoI, - ), - ) - : null; +class TagExamPage extends StatelessWidget { + static const routeName = '/train/tag_exam'; - final boardSettings = BoardSettings( - size: widget.taskSource.task.boardSize, - subBoard: SubBoard( - topLeft: widget.taskSource.task.topLeft, - size: widget.taskSource.task.subBoardSize, - ), - theme: context.settings.boardTheme, - edgeLine: context.settings.edgeLine, - border: border, - stoneShadows: context.settings.stoneShadows, - ); + static const taskCount = 10; + static const timePerTask = const Duration(minutes: 5); + static const maxMistakes = 2; - final board = LayoutBuilder( - builder: (context, constraints) { - final boardSize = constraints.biggest.shortestSide - - 2 * (boardSettings.border?.size ?? 0); - return Board( - size: boardSize, - settings: boardSettings, - onPointClicked: (p) => _onPointClicked(p, wideLayout), - turn: _turn, - stones: _gameTree.stones, - annotations: _continuationAnnotations ?? _gameTree.annotations, - confirmTap: context.settings.confirmMoves, - ); - }, - ); - - final taskTitle = - '[${widget.taskSource.task.rank.toString()}] ${widget.taskSource.task.type.toString()}'; - - final timeDisplay = TimeDisplay( - key: _timeDisplayKey, - timeState: const TimeState( - mainTimeLeft: _timePerTask, - periodTimeLeft: Duration.zero, - periodCount: 0, - ), - warningDuration: const Duration(seconds: 9), - enabled: _solveStatus == null, - tickerEnabled: true, - voiceCountdown: false, - onTimeout: () => _onTimeout(wideLayout), - ); - - if (wideLayout) { - return Scaffold( - body: Center( - child: Row( - children: [ - Expanded(child: Center(child: board)), - VerticalDivider(thickness: 1, width: 8), - _SideBar( - taskTitle: taskTitle, - taskNumber: _taskNumber, - color: widget.taskSource.task.first, - status: _solveStatus, - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, - onCancelExam: () { - context.stats - .incrementTagExamFailCount(widget.tag, widget.rankRange); - Navigator.popUntil(context, (route) => route.isFirst); - }, - timeDisplay: timeDisplay, - ), - ], - ), - ), - ); - } else { - return Scaffold( - appBar: AppBar( - leading: Center(child: Text('$_taskNumber/10')), - title: Row( - spacing: 4, - children: [ - TurnIcon(color: widget.taskSource.task.first), - Text(taskTitle), - ], - ), - actions: [ - IconButton( - icon: Icon(Icons.cancel), - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Confirm', - content: - 'Are you sure that you want to stop the Topic Exam?', - onYes: () { - context.stats.incrementTagExamFailCount( - widget.tag, widget.rankRange); - Navigator.popUntil(context, (route) => route.isFirst); - }, - onNo: () { - Navigator.pop(context); - }, - ), - ); - }, - ), - ], - ), - body: Center( - child: board, - ), - bottomNavigationBar: BottomAppBar( - child: (_solveStatus == null) - ? Center(child: timeDisplay) - : TaskActionBar( - onShowSolution: _onShowContinuations, - onShare: _onShare, - onReplay: _onReplay, - onNext: _onNext, - ), - ), - ); - } - } - - void _onReplay() { - setState(() { - _setupCurrentTask(); - }); - } - - void _onNext() { - if (_taskNumber == 10) { - if (_mistakeCount <= 2) { - context.stats.incrementTagExamPassCount(widget.tag, widget.rankRange); - } else { - context.stats.incrementTagExamFailCount(widget.tag, widget.rankRange); - } - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => _ResultDialog( - totalTime: _totalTime, - mistakeCount: _mistakeCount, - onExit: () => Navigator.popUntil(context, (route) => route.isFirst), - onRedo: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => PopScope( - canPop: false, - child: TagExamPage( - tag: widget.tag, - rankRange: widget.rankRange, - taskSource: ConstTaskSource( - tasks: TaskRepository() - .readByTag(widget.tag, widget.rankRange, 10)), - ), - ), - ), - ); - }, - onNext: null, - ), - ); - return; - } - widget.taskSource - .next(_solveStatus ?? VariationStatus.wrong, _stopwatch.elapsed); - _taskNumber++; - _solveStatus = null; - setState(() { - _setupCurrentTask(); - }); - _stopwatch.reset(); - _stopwatch.start(); - } - - void _setupCurrentTask() { - _continuationAnnotations = null; - _vtreeIt = - VariationTreeIterator(tree: widget.taskSource.task.variationTree); - _gameTree = AnnotatedGameTree(widget.taskSource.task.boardSize); - for (final entry in widget.taskSource.task.initialStones.entries) { - for (final p in entry.value) { - _gameTree - .moveAnnotated((col: entry.key, p: p), mode: AnnotationMode.none); - } - } - _turn = widget.taskSource.task.first; - _solveStatusNotified = false; - } - - void _onPointClicked(wq.Point p, bool wideLayout) { - if (!(_solveStatusNotified || _turn == widget.taskSource.task.first)) { - return; - } - - if (_gameTree.moveAnnotated((col: _turn, p: p), - mode: AnnotationMode.variation) != - null) { - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } - _continuationAnnotations = null; - final status = _vtreeIt!.move(p); - _turn = _turn.opposite; - if (status != null) { - _setSolveStatus(status, wideLayout); - } else { - switch (context.settings.responseDelay) { - case ResponseDelay.none: - _generateResponseMove(wideLayout); - default: - Future.delayed(context.settings.responseDelay.duration, () { - _generateResponseMove(wideLayout); - }); - } - } - setState(() {/* Update board */}); - } - } - - void _generateResponseMove(bool wideLayout) { - if (_solveStatusNotified) return; - - final resp = _vtreeIt!.genMove(); - _gameTree - .moveAnnotated((col: _turn, p: resp), mode: AnnotationMode.variation); - _turn = _turn.opposite; - final status = _vtreeIt!.move(resp); - if (status != null) { - _setSolveStatus(status, wideLayout); - } - setState(() {/* Update board */}); - } - - void _setSolveStatus(VariationStatus status, bool wideLayout) { - setState(() { - _continuationAnnotations = null; - }); - if (!_solveStatusNotified) { - notifySolveStatus(status, wideLayout); - _solveStatusNotified = true; - } - if (_solveStatus == null) { - final curTask = widget.taskSource.task; - StatsDB().addTaskAttempt(curTask.rank, curTask.type, curTask.id, - status == VariationStatus.correct); - if (status == VariationStatus.correct) { - if (context.settings.sound) AudioController().correct(); - context.stats.incrementTotalPassCount(curTask.rank); - } else { - if (context.settings.sound) AudioController().wrong(); - context.stats.incrementTotalFailCount(curTask.rank); - _mistakeCount++; - } - _stopwatch.stop(); - _totalTime += _stopwatch.elapsed; - _solveStatus = status; - } - } - - void _onTimeout(bool wideLayout) { - if (_solveStatus == null) { - _mistakeCount++; - _totalTime += _timePerTask; - _solveStatus = VariationStatus.wrong; - if (!_solveStatusNotified) { - notifySolveTimeout(wideLayout); - _solveStatusNotified = true; - } - setState(() {}); - } - } - - _onShowContinuations() { - _continuationAnnotations = IMapOfSets.empty(); - for (final (p, st) - in _vtreeIt?.continuations() ?? <(wq.Point, VariationStatus)>[]) { - _continuationAnnotations = _continuationAnnotations?.add(p, ( - type: AnnotationShape.dot.u21, - color: switch (st) { - VariationStatus.correct => Colors.green, - VariationStatus.wrong => Colors.red, - }, - )); - } - if (_continuationAnnotations?.isNotEmpty ?? false) { - setState(() { - // Update board annotations - }); - } - } - - _onShare() { - final link = widget.taskSource.task.deepLink(); - Clipboard.setData(ClipboardData(text: link)).then((void _) { - if (context.mounted) notifyTaskLinkCopied(); - }); - } -} - -class _SideBar extends StatelessWidget { - final String taskTitle; - final int taskNumber; - final wq.Color color; - final VariationStatus? status; - final Function()? onShowSolution; - final Function()? onShare; - final Function()? onReplay; - final Function()? onNext; - final Function() onCancelExam; - final Widget timeDisplay; + final TaskTag tag; + final RankRange rankRange; - const _SideBar({ - required this.taskTitle, - required this.taskNumber, - required this.color, - this.status, - required this.onShowSolution, - required this.onShare, - required this.onReplay, - required this.onNext, - required this.onCancelExam, - required this.timeDisplay, - }); + const TagExamPage({super.key, required this.tag, required this.rankRange}); @override Widget build(BuildContext context) { - final widgetSize = MediaQuery.sizeOf(context); - final sidebarSize = min( - widgetSize.longestSide - widgetSize.shortestSide, widgetSize.width / 3); - return SizedBox( - width: sidebarSize, - child: Container( - padding: EdgeInsets.all(8), - color: ColorScheme.of(context).surfaceContainer, - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Text( - '$taskNumber/10', - textAlign: TextAlign.center, - )), - IconButton( - icon: Icon(Icons.cancel), - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Confirm', - content: - 'Are you sure that you want to stop the Topic Exam?', - onYes: onCancelExam, - onNo: () { - Navigator.pop(context); - }, - ), - ); - }, - ), - ], - ), - Row( - spacing: 8, - mainAxisSize: MainAxisSize.min, - children: [ - TurnIcon(color: color), - Text(taskTitle), - ], - ), - Expanded(child: Container()), - (status == null) - ? Center( - child: timeDisplay, - ) - : TaskActionBar( - onShowSolution: onShowSolution, - onShare: onShare, - onNext: onNext, - onReplay: onReplay, - ), - ], - ), - ), + final loc = AppLocalizations.of(context)!; + final ranks = tag.ranks(); + final currentIndex = ranks.indexWhere( + (rank) => rank.from == rankRange.from && rank.to == rankRange.to); + final hasNextRank = currentIndex != -1 && currentIndex + 1 < ranks.length; + + return ExamPage( + title: loc.topicExam, + examEvent: ExamEvent(type: ExamType.topic, tag: tag), + rankRange: rankRange, + taskCount: taskCount, + timePerTask: timePerTask, + maxMistakes: maxMistakes, + createTaskSource: createTaskSource, + onPass: () => context.stats.incrementTagExamPassCount(tag, rankRange), + onFail: () => context.stats.incrementTagExamFailCount(tag, rankRange), + baseRoute: routeName, + exitRoute: SubtagRankSelectionPage.routeName, + redoRouteArguments: TagExamRouteArguments(tag: tag, rankRange: rankRange), + nextRouteArguments: hasNextRank + ? TagExamRouteArguments(tag: tag, rankRange: ranks[currentIndex + 1]) + : null, ); } -} - -class _ResultDialog extends StatelessWidget { - final Duration totalTime; - final int mistakeCount; - final Function() onExit; - final Function() onRedo; - final Function()? onNext; - const _ResultDialog( - {required this.totalTime, - required this.mistakeCount, - required this.onExit, - required this.onRedo, - required this.onNext}); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: (mistakeCount <= 2) - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.verified), const Text('Passed')], - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.cancel), const Text('Failed')], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: const Text('Total time'), - trailing: Text(totalTime.toString().substring(2, 7)), - ), - ListTile( - title: const Text('Avg time per task'), - trailing: Text('${(totalTime.inSeconds / 10).toStringAsFixed(1)}s'), - ), - ListTile( - title: const Text('Mistakes'), - trailing: Text('$mistakeCount'), - ), - ], - ), - actionsAlignment: MainAxisAlignment.center, - actions: [ - TextButton( - onPressed: onExit, - child: const Text('Exit'), - ), - TextButton( - onPressed: onRedo, - child: const Text('Redo'), - ), - if (onNext != null) - TextButton( - onPressed: onNext, - child: const Text('Next'), - ), - ], + TaskSource createTaskSource(BuildContext context) { + return BlackToPlaySource( + source: ConstTaskSource( + tasks: TaskRepository().readByTag(tag, rankRange, taskCount)), + blackToPlay: context.settings.alwaysBlackToPlay, ); } } diff --git a/lib/train/tags_page.dart b/lib/train/tags_page.dart index e3a17f39..943e3426 100644 --- a/lib/train/tags_page.dart +++ b/lib/train/tags_page.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/train/subtags_page.dart'; import 'package:wqhub/train/tag_completion_rate.dart'; import 'package:wqhub/train/task_tag.dart'; class TagsPage extends StatelessWidget { + static const routeName = '/train/tags'; + @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( - title: const Text('Topics'), + title: Text(loc.topics), ), body: Center( child: ListView( @@ -45,15 +49,15 @@ class _TagTile extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return ListTile( - title: Text(tag.toString()), + title: Text(tag.toLocalizedString(loc)), trailing: TagCompletionRate(tag: tag), onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => SubtagsPage(tag: tag), - ), + SubtagsPage.routeName, + arguments: SubtagsRouteArguments(tag: tag), ); }, ); diff --git a/lib/train/task_action_bar.dart b/lib/train/task_action_bar.dart index c3c31a45..b51bb8e2 100644 --- a/lib/train/task_action_bar.dart +++ b/lib/train/task_action_bar.dart @@ -1,48 +1,142 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/train/upsolve_mode.dart'; class TaskActionBar extends StatelessWidget { + static const fastMoveNavCount = 5; + + final UpsolveMode upsolveMode; final Function()? onShowSolution; - final Function()? onShare; - final Function()? onReplay; - final Function()? onNext; + final Function()? onShareTask; + final Function()? onResetTask; + final Function()? onNextTask; + final Function() onPreviousMove; + final Function() onNextMove; + final Function(UpsolveMode) onUpdateUpsolveMode; - const TaskActionBar( - {super.key, - this.onShowSolution, - this.onShare, - this.onReplay, - this.onNext}); + const TaskActionBar({ + super.key, + required this.upsolveMode, + this.onShowSolution, + this.onShareTask, + this.onResetTask, + this.onNextTask, + required this.onPreviousMove, + required this.onNextMove, + required this.onUpdateUpsolveMode, + }); @override Widget build(BuildContext context) { - return Row( + final loc = AppLocalizations.of(context)!; + final backButton = Expanded( + child: IconButton( + icon: Icon(Icons.undo), + onPressed: () => onUpdateUpsolveMode(UpsolveMode.auto), + ), + ); + final menuButton = Expanded( + child: PopupMenuButton( + icon: Icon(Icons.menu), + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + onTap: onShareTask, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + Icon(Icons.share), + Text(loc.copyTaskLink), + ], + ), + ), + PopupMenuItem( + onTap: () => onUpdateUpsolveMode(UpsolveMode.manual), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: switch (upsolveMode) { + UpsolveMode.auto => [ + Icon(Icons.touch_app), + Text(loc.tryCustomMoves), + ], + UpsolveMode.manual => [ + Icon(Icons.touch_app), + Text(loc.exitTryMode), + ], + }, + ), + ), + ], + ), + ); + final mainBar = Row( children: [ + if (upsolveMode == UpsolveMode.auto) menuButton else backButton, Expanded( child: IconButton( icon: Icon(Icons.lightbulb), onPressed: onShowSolution, ), ), - Expanded( - child: IconButton( - icon: Icon(Icons.share), - onPressed: onShare, - ), - ), Expanded( child: IconButton( icon: Icon(Icons.replay), - onPressed: onReplay, + onPressed: onResetTask, ), ), - if (onNext != null) + if (onNextTask != null) Expanded( child: IconButton( icon: Icon(Icons.navigate_next), - onPressed: onNext, + onPressed: onNextTask, ), ), ], ); + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (upsolveMode == UpsolveMode.manual) + Row( + children: [ + Expanded( + child: IconButton( + icon: Icon(Icons.fast_rewind), + onPressed: () { + for (int i = 0; i < fastMoveNavCount; ++i) onPreviousMove(); + }, + ), + ), + Expanded( + child: IconButton( + icon: Transform.flip( + flipX: true, + child: Icon(Icons.play_arrow), + ), + onPressed: onPreviousMove, + ), + ), + Expanded( + child: IconButton( + icon: Icon(Icons.play_arrow), + onPressed: onNextMove, + ), + ), + Expanded( + child: IconButton( + icon: Icon(Icons.fast_forward), + onPressed: () { + for (int i = 0; i < fastMoveNavCount; ++i) onNextMove(); + }, + ), + ), + ], + ), + if (upsolveMode == UpsolveMode.manual) Divider(height: 8.0), + mainBar, + ], + ); } } diff --git a/lib/train/task_preview_tile.dart b/lib/train/task_preview_tile.dart index 44748661..cb074d45 100644 --- a/lib/train/task_preview_tile.dart +++ b/lib/train/task_preview_tile.dart @@ -85,10 +85,10 @@ class _TaskPreviewTileState extends State { Expanded( child: GestureDetector( onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => SingleTaskPage(task: task)), + SingleTaskPage.routeName, + arguments: SingleTaskRouteArguments(task: task), ); }, onLongPressStart: (details) { diff --git a/lib/train/task_repository.dart b/lib/train/task_repository.dart index c88a10b3..9998f31f 100644 --- a/lib/train/task_repository.dart +++ b/lib/train/task_repository.dart @@ -5,6 +5,8 @@ import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import 'package:wqhub/random_util.dart'; import 'package:wqhub/train/rank_range.dart'; +import 'package:wqhub/train/task_source/distribution_task_source.dart'; +import 'package:wqhub/train/task_source/task_source.dart'; import 'package:wqhub/train/task_tag.dart'; import 'package:wqhub/train/task_type.dart'; import 'package:wqhub/train/variation_tree.dart'; @@ -20,6 +22,19 @@ class TaskRef { const TaskRef({required this.rank, required this.type, required this.id}); + factory TaskRef.ofUri(String link) { + final uri = Uri.parse(link); + if (uri.scheme != 'wqhub') { + throw FormatException('unrecognized scheme: ${uri.scheme}'); + } + final p = uri.pathSegments.last; + return TaskRef( + rank: Rank.values[int.parse(p.substring(0, 2), radix: 16)], + type: TaskType.values[int.parse(p.substring(2, 4), radix: 16)], + id: int.parse(p.substring(4), radix: 16), + ); + } + @override int get hashCode => Object.hash(rank, type, id); @@ -414,7 +429,7 @@ class TaskRepository { return _buckets[(rank, type)]?.readOne(); } - IList read(wq.Rank rank, ISet types, int n) { + IList readByTypes(Rank rank, ISet types, int n) { assert(types.isNotEmpty); final bucketDist = types .mapNotNull((t) => _buckets[(rank, t)]) @@ -460,6 +475,56 @@ class TaskRepository { return IList(tasks); } + TaskSource taskSourceByTypes(RankRange rankRange, ISet taskTypes) { + final buckets = [ + for (int i = rankRange.from.index; i <= rankRange.to.index; ++i) + for (final taskType in taskTypes) + if (_buckets[(Rank.values[i], taskType)] != null) + ( + _buckets[(Rank.values[i], taskType)]!, + _buckets[(Rank.values[i], taskType)]!.size, + ) + ]; + return DistributionTaskSource( + buckets: buckets, + nextTask: (_TaskBucket bucket) => bucket.readOne(), + ); + } + + TaskSource taskSourceByTag(RankRange rankRange, TaskTag tag) { + final buckets = [ + for (int i = rankRange.from.index; i <= rankRange.to.index; ++i) + if (_tags.tasks[(Rank.values[i], tag)] != null) + ( + (Rank.values[i], _tags.tasks[(Rank.values[i], tag)]!), + _tags.tasks[(Rank.values[i], tag)]?.tasks.length ?? 0 + ) + ]; + return DistributionTaskSource( + buckets: buckets, + nextTask: ((Rank, _TagBucket) bucket) { + final (rank, tagBucket) = bucket; + final (type, id) = tagBucket.next(); + return readById(rank, type, id)!; + }, + ); + } + + int countByTypes(RankRange rankRange, ISet types) { + var total = 0; + for (int i = rankRange.from.index; i <= rankRange.to.index; ++i) + for (final type in types) + total += _buckets[(Rank.values[i], type)]?.size ?? 0; + return total; + } + + int countByTag(RankRange rankRange, TaskTag tag) { + var total = 0; + for (int i = rankRange.from.index; i <= rankRange.to.index; ++i) + total += _tags.tasks[(Rank.values[i], tag)]?.tasks.length ?? 0; + return total; + } + TaskCollection collections() => _collectionRoot; TreeNode collectionsTreeNode() => _collectionTreeNode; diff --git a/lib/train/task_solving_state_mixin.dart b/lib/train/task_solving_state_mixin.dart new file mode 100644 index 00000000..cf536771 --- /dev/null +++ b/lib/train/task_solving_state_mixin.dart @@ -0,0 +1,233 @@ +import 'dart:math'; + +import 'package:extension_type_unions/extension_type_unions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:wqhub/audio/audio_controller.dart'; +import 'package:wqhub/board/board_annotation.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; +import 'package:wqhub/train/response_delay.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/upsolve_mode.dart'; +import 'package:wqhub/train/variation_tree.dart'; +import 'package:wqhub/wq/annotated_game_tree.dart'; +import 'package:wqhub/wq/wq.dart' as wq; + +mixin TaskSolvingStateMixin on State { + VariationTreeIterator? _vtreeIt; + var gameTree = AnnotatedGameTree(19); + var turn = wq.Color.black; + VariationStatus? solveStatus; + bool solveStatusNotified = false; + IMapOfSets? continuationAnnotations; + var upsolveMode = UpsolveMode.auto; + + Task get currentTask; + void onSolveStatus(VariationStatus status); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + setupCurrentTask(); + } + + void setupCurrentTask() { + continuationAnnotations = null; + _vtreeIt = VariationTreeIterator(tree: currentTask.variationTree); + gameTree = AnnotatedGameTree(currentTask.boardSize); + for (final entry in currentTask.initialStones.entries) { + for (final p in entry.value) { + gameTree + .moveAnnotated((col: entry.key, p: p), mode: AnnotationMode.none); + } + } + turn = currentTask.first; + solveStatusNotified = false; + upsolveMode = UpsolveMode.auto; + } + + void onResetTask() { + setState(() { + setupCurrentTask(); + }); + } + + void onMove(wq.Point p, bool wideLayout) { + if (!(solveStatusNotified || + turn == currentTask.first || + upsolveMode == UpsolveMode.manual)) { + return; + } + + if (gameTree + .moveAnnotated((col: turn, p: p), mode: AnnotationMode.variation) != + null) { + AudioController().playForNode(gameTree.curNode); + continuationAnnotations = null; + final status = _vtreeIt!.move(p); + turn = turn.opposite; + if (status != null) { + _setSolveStatus(status, wideLayout); + } else if (upsolveMode == UpsolveMode.auto) { + switch (context.settings.responseDelay) { + case ResponseDelay.none: + _generateResponseMove(wideLayout); + default: + Future.delayed(context.settings.responseDelay.duration, () { + _generateResponseMove(wideLayout); + }); + } + } + setState(() {/* Update board */}); + } + } + + void onPreviousMove() { + if ((_vtreeIt?.depth ?? 0) > 0) { + gameTree.undo(); + _vtreeIt?.undo(); + continuationAnnotations = null; + turn = turn.opposite; + setState(() {/* Update board */}); + } + } + + void onNextMove() { + final p = _vtreeIt?.redo(); + if (p != null) { + gameTree.moveAnnotated((col: turn, p: p), mode: AnnotationMode.variation); + continuationAnnotations = null; + turn = turn.opposite; + setState(() {/* Update board */}); + } + } + + void _generateResponseMove(bool wideLayout) { + if (solveStatusNotified) return; + + final resp = _vtreeIt!.genMove(); + gameTree + .moveAnnotated((col: turn, p: resp), mode: AnnotationMode.variation); + turn = turn.opposite; + final status = _vtreeIt!.move(resp); + if (status != null) { + _setSolveStatus(status, wideLayout); + } + setState(() {/* Update board */}); + } + + void _setSolveStatus(VariationStatus status, bool wideLayout) { + setState(() { + continuationAnnotations = null; + }); + if (!solveStatusNotified) { + notifySolveStatus(status, wideLayout); + solveStatusNotified = true; + } + if (solveStatus == null) { + if (status == VariationStatus.correct) { + AudioController().correct(); + } else { + AudioController().wrong(); + } + solveStatus = status; + onSolveStatus(status); + } + } + + void onShowContinuations() { + continuationAnnotations = IMapOfSets.empty(); + for (final (p, st) + in _vtreeIt?.continuations() ?? <(wq.Point, VariationStatus)>[]) { + continuationAnnotations = continuationAnnotations?.add(p, ( + type: AnnotationShape.dot.u21, + color: switch (st) { + VariationStatus.correct => Colors.green, + VariationStatus.wrong => Colors.red, + }, + )); + } + if (continuationAnnotations?.isNotEmpty ?? false) { + setState(() { + // Update board annotations + }); + } + } + + void onShareTask() { + final link = currentTask.deepLink(); + Clipboard.setData(ClipboardData(text: link)).then((void _) { + if (context.mounted) notifyTaskLinkCopied(); + }); + } + + void onUpdateUpsolveMode(UpsolveMode newMode) { + if (upsolveMode != newMode) { + setupCurrentTask(); + setState(() { + upsolveMode = newMode; + }); + } + } + + void notifySolveStatus(VariationStatus status, bool wideLayout) { + final loc = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + Icon(status == VariationStatus.correct + ? Icons.check_circle + : Icons.sentiment_very_dissatisfied), + Text(status.toLocalizedString(loc), + style: TextTheme.of(context).titleMedium), + ], + ), + behavior: wideLayout ? SnackBarBehavior.floating : SnackBarBehavior.fixed, + margin: _snackBarMargin(wideLayout), + duration: Duration(seconds: 1), + backgroundColor: + status == VariationStatus.correct ? Colors.green : Colors.red, + showCloseIcon: true, + )); + } + + void notifySolveTimeout(bool wideLayout) { + final loc = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + Icon(Icons.timer), + Text(loc.taskTimeout, style: TextTheme.of(context).titleMedium), + ], + ), + behavior: wideLayout ? SnackBarBehavior.floating : SnackBarBehavior.fixed, + margin: _snackBarMargin(wideLayout), + duration: Duration(seconds: 1), + backgroundColor: Colors.red, + showCloseIcon: true, + )); + } + + void notifyTaskLinkCopied() { + final loc = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(loc.msgTaskLinkCopied))); + } + + EdgeInsetsGeometry? _snackBarMargin(bool wideLayout) { + if (wideLayout) { + final widgetSize = MediaQuery.sizeOf(context); + final sidebarSize = min(widgetSize.longestSide - widgetSize.shortestSide, + widgetSize.width / 3); + return EdgeInsets.only(right: sidebarSize); + } else { + return null; + } + } +} diff --git a/lib/train/task_source/const_task_ref_source.dart b/lib/train/task_source/const_task_ref_source.dart new file mode 100644 index 00000000..3f98f987 --- /dev/null +++ b/lib/train/task_source/const_task_ref_source.dart @@ -0,0 +1,25 @@ +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_source/task_source.dart'; + +final class ConstTaskRefSource extends TaskSource { + final List taskRefs; + int _cur = 0; + late Task _curTask = TaskRepository().readByRef(taskRefs.first)!; + + ConstTaskRefSource({required this.taskRefs}) : assert(taskRefs.isNotEmpty); + + @override + bool next(prevStatus, prevSolveTime, {Function(double)? onRankChanged}) { + _cur++; + if (_cur >= taskRefs.length) _cur = 0; + _curTask = TaskRepository().readByRef(taskRefs[_cur])!; + return true; + } + + @override + Task get task => _curTask; + + @override + double get rank => + throw UnimplementedError('rank not supported on ConstTaskRefSource'); +} diff --git a/lib/train/task_source/distribution_task_source.dart b/lib/train/task_source/distribution_task_source.dart new file mode 100644 index 00000000..e02a9c2f --- /dev/null +++ b/lib/train/task_source/distribution_task_source.dart @@ -0,0 +1,28 @@ +import 'package:wqhub/random_util.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_source/task_source.dart'; +import 'package:wqhub/train/variation_tree.dart'; + +class DistributionTaskSource extends TaskSource { + final List<(T, int)> buckets; + final Task Function(T) nextTask; + late Task _curTask = _takeTask(); + + DistributionTaskSource({required this.buckets, required this.nextTask}); + + @override + bool next(VariationStatus prevStatus, Duration prevSolveTime, + {Function(double p1)? onRankChanged}) { + _curTask = _takeTask(); + return true; + } + + @override + double get rank => + throw UnimplementedError('rank not supported on DistributionTaskSource'); + + @override + Task get task => _curTask; + + Task _takeTask() => nextTask(randomDist(buckets)); +} diff --git a/lib/train/task_source/ranked_mode_task_source.dart b/lib/train/task_source/ranked_mode_task_source.dart index d375577e..2bda6682 100644 --- a/lib/train/task_source/ranked_mode_task_source.dart +++ b/lib/train/task_source/ranked_mode_task_source.dart @@ -20,7 +20,7 @@ final class RankedModeTaskSource extends TaskSource { RankedModeTaskSource(double rank) : _rank = rank, _cur = TaskRepository() - .read(Rank.values[rank.toInt()], _taskTypes, 1) + .readByTypes(Rank.values[rank.toInt()], _taskTypes, 1) .first; @override @@ -32,8 +32,9 @@ final class RankedModeTaskSource extends TaskSource { _rank = max(_rank - _rankDec(prevSolveTime), Rank.k15.index.toDouble()); } onRankChanged?.call(_rank); - _cur = - TaskRepository().read(Rank.values[_rank.toInt()], _taskTypes, 1).first; + _cur = TaskRepository() + .readByTypes(Rank.values[_rank.toInt()], _taskTypes, 1) + .first; return true; } diff --git a/lib/train/task_source/task_source_type.dart b/lib/train/task_source/task_source_type.dart new file mode 100644 index 00000000..6a2798cc --- /dev/null +++ b/lib/train/task_source/task_source_type.dart @@ -0,0 +1,13 @@ +import 'package:wqhub/l10n/app_localizations.dart'; + +enum TaskSourceType { + fromTaskTypes, + fromTaskTag, + fromMistakes; + + String toLocalizedString(AppLocalizations loc) => switch (this) { + TaskSourceType.fromTaskTypes => loc.taskSourceFromTaskTypes, + TaskSourceType.fromTaskTag => loc.taskSourceFromTaskTopic, + TaskSourceType.fromMistakes => loc.taskSourceFromMyMistakes, + }; +} diff --git a/lib/train/task_source/time_frenzy_task_source.dart b/lib/train/task_source/time_frenzy_task_source.dart index b1c6c960..7a9e2dc7 100644 --- a/lib/train/task_source/time_frenzy_task_source.dart +++ b/lib/train/task_source/time_frenzy_task_source.dart @@ -19,7 +19,7 @@ final class TimeFrenzyTaskSource extends TaskSource { int _mistakeCount = 0; TimeFrenzyTaskSource() - : _cur = TaskRepository().read(Rank.k15, _taskTypes, 1).first; + : _cur = TaskRepository().readByTypes(Rank.k15, _taskTypes, 1).first; @override bool next(prevStatus, prevSolveTime, {Function(double)? onRankChanged}) { @@ -31,8 +31,9 @@ final class TimeFrenzyTaskSource extends TaskSource { _rank = max(_rank - _mistakeCount, Rank.k15.index.toDouble()); } onRankChanged?.call(_rank); - _cur = - TaskRepository().read(Rank.values[_rank.toInt()], _taskTypes, 1).first; + _cur = TaskRepository() + .readByTypes(Rank.values[_rank.toInt()], _taskTypes, 1) + .first; return true; } diff --git a/lib/train/task_tag.dart b/lib/train/task_tag.dart index c9222339..1219b7bf 100644 --- a/lib/train/task_tag.dart +++ b/lib/train/task_tag.dart @@ -1,4 +1,5 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/train/rank_range.dart'; import 'package:wqhub/wq/rank.dart'; @@ -484,250 +485,256 @@ enum TaskTag { _ => const IList.empty(), }; - @override - String toString() => switch (this) { - TaskTag.brilliantSequence => "Brilliant sequence", - TaskTag.placement => "Placement", - TaskTag.symmetricShape => "Symmetric shape", - TaskTag.hane => "Hane", - TaskTag.insideKill => "Inside kill", - TaskTag.reduceLiberties => "Reduce liberties", - TaskTag.increaseLiberties => "Increase liberties", - TaskTag.seki => "Seki", - TaskTag.capturingRace => "Capturing race", - TaskTag.blindSpot => "Blind spot", - TaskTag.diagonal => "Diagonal", - TaskTag.tigersMouth => "Tiger's mouth", - TaskTag.underTheStones => "Under the stones", - TaskTag.tombstoneSqueeze => "Tombstone squeeze", - TaskTag.ko => "Ko", - TaskTag.connect => "Connect", - TaskTag.descent => "Descent", - TaskTag.clamp => "Clamp", + String toLocalizedString(AppLocalizations loc) => switch (this) { + TaskTag.brilliantSequence => loc.taskTag_brilliantSequence, + TaskTag.placement => loc.taskTag_placement, + TaskTag.symmetricShape => loc.taskTag_symmetricShape, + TaskTag.hane => loc.taskTag_hane, + TaskTag.insideKill => loc.taskTag_insideKill, + TaskTag.reduceLiberties => loc.taskTag_reduceLiberties, + TaskTag.increaseLiberties => loc.taskTag_increaseLiberties, + TaskTag.seki => loc.taskTag_seki, + TaskTag.capturingRace => loc.taskTag_capturingRace, + TaskTag.blindSpot => loc.taskTag_blindSpot, + TaskTag.diagonal => loc.taskTag_diagonal, + TaskTag.tigersMouth => loc.taskTag_tigersMouth, + TaskTag.underTheStones => loc.taskTag_underTheStones, + TaskTag.tombstoneSqueeze => loc.taskTag_tombstoneSqueeze, + TaskTag.ko => loc.taskTag_ko, + TaskTag.connect => loc.taskTag_connect, + TaskTag.descent => loc.taskTag_descent, + TaskTag.clamp => loc.taskTag_clamp, TaskTag.tripodGroupWithExtraLegAndSimilar => - "Tripod group with extra leg and similar", - TaskTag.sacrificeAndSqueeze => "Sacrifice and squeeze", - TaskTag.cranesNest => "Crane's nest", - TaskTag.throwIn => "Throw-in", - TaskTag.eyeVsNoEye => "Eye vs no-eye", - TaskTag.bigEyeVsSmallEye => "Big eye vs small eye", - TaskTag.twoHaneGainOneLiberty => "Double hane grows one liberty", - TaskTag.lovesickCut => "Lovesick cut", - TaskTag.doubleKo => "Double ko", - TaskTag.internalLibertyShortage => "Internal liberty shortage", - TaskTag.wedge => "Wedge", - TaskTag.jGroupAndSimilar => "J-group and similar", - TaskTag.bentFourInTheCorner => "Bent four in the corner", - TaskTag.multipleBrilliantMoves => "Multiple brilliant moves", + loc.taskTag_tripodGroupWithExtraLegAndSimilar, + TaskTag.sacrificeAndSqueeze => loc.taskTag_sacrificeAndSqueeze, + TaskTag.cranesNest => loc.taskTag_cranesNest, + TaskTag.throwIn => loc.taskTag_throwIn, + TaskTag.eyeVsNoEye => loc.taskTag_eyeVsNoEye, + TaskTag.bigEyeVsSmallEye => loc.taskTag_bigEyeVsSmallEye, + TaskTag.twoHaneGainOneLiberty => loc.taskTag_twoHaneGainOneLiberty, + TaskTag.lovesickCut => loc.taskTag_lovesickCut, + TaskTag.doubleKo => loc.taskTag_doubleKo, + TaskTag.internalLibertyShortage => loc.taskTag_internalLibertyShortage, + TaskTag.wedge => loc.taskTag_wedge, + TaskTag.jGroupAndSimilar => loc.taskTag_jGroupAndSimilar, + TaskTag.bentFourInTheCorner => loc.taskTag_bentFourInTheCorner, + TaskTag.multipleBrilliantMoves => loc.taskTag_multipleBrilliantMoves, TaskTag.goldenChickenStandingOnOneLeg => - "Golden rooster standing on one leg", - TaskTag.carpentersSquareAndSimilar => "Carpenter's square and similar", - TaskTag.underneathAttachment => "Underneath attachment", - TaskTag.orioleCapturesButterfly => "Oriole captures the butterfly", - TaskTag.mouseStealingOil => "Mouse stealing oil", - TaskTag.kosumiWedge => "Kosumi wedge", - TaskTag.cut => "Cut", - TaskTag.knightsMove => "Knight's move", - TaskTag.jump => "Jump", - TaskTag.bump => "Bump", - TaskTag.solidConnection => "Solid connection", - TaskTag.cutAcross => "Cut across", - TaskTag.settleShape => "Settle shape", - TaskTag.attack => "Attack", - TaskTag.net => "Net", - TaskTag.contactPlay => "Contact play", - TaskTag.pyramidFour => "Pyramid four", - TaskTag.sacrifice => "Sacrifice", - TaskTag.captureTwoRecaptureOne => "Capture two, recapture one", - TaskTag.directionOfCapture => "Direction of capture", - TaskTag.closeInCapture => "Closing-in capture", - TaskTag.captureBySnapback => "Capture by snapback", - TaskTag.wedgingCapture => "Wedging capture", - TaskTag.twoHeadedDragon => "Two-headed dragon", - TaskTag.escape => "Escape", - TaskTag.directionOfEscape => "Direction of escape", - TaskTag.solidExtension => "Solid extension", - TaskTag.bend => "Bend", - TaskTag.netCapture => "Net capture", - TaskTag.cut2 => "Cut", - TaskTag.looseLadder => "Loose ladder", - TaskTag.beginner => "Beginner", - TaskTag.captureInOneMove => "Capture in one move", - TaskTag.escapeInOneMove => "Escape in one move", - TaskTag.connectInOneMove => "Connect in one move", - TaskTag.splitInOneMove => "Split in one move", - TaskTag.doubleAtari => "Double atari", - TaskTag.engulfingAtari => "Engulfing atari", - TaskTag.captureInLadder => "Capture in ladder", - TaskTag.snapback => "Snapback", - TaskTag.connectAndDie => "Connect and die", - TaskTag.captureOnTheSide => "Capture on the side", - TaskTag.middlegame => "Middlegame", - TaskTag.lifeAndDeath => "Life & death: general", - TaskTag.invasion => "Invasion", - TaskTag.findTheRoot => "Find the root", - TaskTag.reduction => "Reduction", - TaskTag.sealIn => "Seal in", - TaskTag.vitalPointForBothSides => "Vital point for both sides", - TaskTag.basicLifeAndDeath => "Life & death: basic", - TaskTag.straightThree => "Straight three", - TaskTag.bentThree => "Bent three", - TaskTag.bulkyFive => "Bulky five", - TaskTag.crossedFive => "Crossed five", - TaskTag.flowerSix => "Flower six", - TaskTag.increaseEyeSpace => "Increase eye space", - TaskTag.threeEyesTwoActions => "Three eyes, two actions", - TaskTag.reduceEyeSpace => "Reduce eye space", - TaskTag.insideMoves => "Inside moves", - TaskTag.preventBambooJoint => "Prevent the bamboo joint", - TaskTag.commonLifeAndDeath => "Life & death: common shapes", - TaskTag.secondLine => "Second line", - TaskTag.makeEyeInOneStep => "Make eye in one step", - TaskTag.breakEyeInOneStep => "Break eye in one step", - TaskTag.basicTesuji => "Tesuji", - TaskTag.basicEndgame => "Endgame: basic", - TaskTag.doubleSenteEndgame => "Double sente endgame", - TaskTag.breakPoints => "Break points", - TaskTag.defendPoints => "Defend points", - TaskTag.opening => "Opening", - TaskTag.influenceKeyPoints => "Influence key points", - TaskTag.bigPoints => "Big points", - TaskTag.urgentPoints => "Urgent points", - TaskTag.twoSpaceExtension => "Two-space extension", - TaskTag.pincer => "Pincer", - TaskTag.comprehensiveTasks => "Comprehensive tasks", - TaskTag.groupLiberties => "Group liberties", - TaskTag.endgame => "Endgame: general", - TaskTag.smallBoardEndgame => "Small board endgame", - TaskTag.orderOfMoves => "Order of moves", - TaskTag.orderOfEndgameMoves => "Order of endgame moves", - TaskTag.useShortageOfLiberties => "Use shortage of liberties", - TaskTag.exploitShapeWeakness => "Exploit shape weakness", - TaskTag.firstLineBrilliantMove => "First line brilliant move", - TaskTag.makeEyeInSente => "Make eye in sente", - TaskTag.breakEyeInSente => "Break eye in sente", - TaskTag.corner => "Corner", - TaskTag.side => "Side", - TaskTag.makeKo => "Make ko", - TaskTag.avoidKo => "Avoid ko", - TaskTag.killAfterCapture => "Kill after capture", - TaskTag.combination => "Combination", - TaskTag.createShortageOfLiberties => "Create shortage of liberties", - TaskTag.useSurroundingStones => "Use surrounding stones", - TaskTag.useCornerSpecialProperties => "Use corner special properties", - TaskTag.oneStoneTwoPurposes => "One stone, two purposes", - TaskTag.useSnapback => "Use snapback", - TaskTag.vitalPointForLife => "Vital point for life", - TaskTag.lookForLeverage => "Look for leverage", - TaskTag.vitalPointForKill => "Vital point for kill", - TaskTag.useConnectAndDie => "Use connect and die", - TaskTag.doNotUnderestimateOpponent => "Do not underestimate opponent", - TaskTag.typesOfKo => "Types of ko", - TaskTag.captureToLive => "Capture to live", - TaskTag.avoidMakingDeadShape => "Avoid making dead shape", - TaskTag.defendFromInvasion => "Defend from invasion", - TaskTag.indirectAttack => "Indirect attack", - TaskTag.vitalPointForCapturingRace => "Vital point for capturing race", - TaskTag.largeKnightsMove => "Large knight move", - TaskTag.keepSente => "Keep sente", - TaskTag.doubleSnapback => "Double snapback", - TaskTag.killByEyePointPlacement => "Kill by eye point placement", - TaskTag.push => "Push", - TaskTag.bridgeUnder => "Bridge under", + loc.taskTag_goldenChickenStandingOnOneLeg, + TaskTag.carpentersSquareAndSimilar => + loc.taskTag_carpentersSquareAndSimilar, + TaskTag.underneathAttachment => loc.taskTag_underneathAttachment, + TaskTag.orioleCapturesButterfly => loc.taskTag_orioleCapturesButterfly, + TaskTag.mouseStealingOil => loc.taskTag_mouseStealingOil, + TaskTag.kosumiWedge => loc.taskTag_kosumiWedge, + TaskTag.cut => loc.taskTag_cut, + TaskTag.knightsMove => loc.taskTag_knightsMove, + TaskTag.jump => loc.taskTag_jump, + TaskTag.bump => loc.taskTag_bump, + TaskTag.solidConnection => loc.taskTag_solidConnection, + TaskTag.cutAcross => loc.taskTag_cutAcross, + TaskTag.settleShape => loc.taskTag_settleShape, + TaskTag.attack => loc.taskTag_attack, + TaskTag.net => loc.taskTag_net, + TaskTag.contactPlay => loc.taskTag_contactPlay, + TaskTag.pyramidFour => loc.taskTag_pyramidFour, + TaskTag.sacrifice => loc.taskTag_sacrifice, + TaskTag.captureTwoRecaptureOne => loc.taskTag_captureTwoRecaptureOne, + TaskTag.directionOfCapture => loc.taskTag_directionOfCapture, + TaskTag.closeInCapture => loc.taskTag_closeInCapture, + TaskTag.captureBySnapback => loc.taskTag_captureBySnapback, + TaskTag.wedgingCapture => loc.taskTag_wedgingCapture, + TaskTag.twoHeadedDragon => loc.taskTag_twoHeadedDragon, + TaskTag.escape => loc.taskTag_escape, + TaskTag.directionOfEscape => loc.taskTag_directionOfEscape, + TaskTag.solidExtension => loc.taskTag_solidExtension, + TaskTag.bend => loc.taskTag_bend, + TaskTag.netCapture => loc.taskTag_netCapture, + TaskTag.cut2 => loc.taskTag_cut2, + TaskTag.looseLadder => loc.taskTag_looseLadder, + TaskTag.beginner => loc.taskTag_beginner, + TaskTag.captureInOneMove => loc.taskTag_captureInOneMove, + TaskTag.escapeInOneMove => loc.taskTag_escapeInOneMove, + TaskTag.connectInOneMove => loc.taskTag_connectInOneMove, + TaskTag.splitInOneMove => loc.taskTag_splitInOneMove, + TaskTag.doubleAtari => loc.taskTag_doubleAtari, + TaskTag.engulfingAtari => loc.taskTag_engulfingAtari, + TaskTag.captureInLadder => loc.taskTag_captureInLadder, + TaskTag.snapback => loc.taskTag_snapback, + TaskTag.connectAndDie => loc.taskTag_connectAndDie, + TaskTag.captureOnTheSide => loc.taskTag_captureOnTheSide, + TaskTag.middlegame => loc.taskTag_middlegame, + TaskTag.lifeAndDeath => loc.taskTag_lifeAndDeath, + TaskTag.invasion => loc.taskTag_invasion, + TaskTag.findTheRoot => loc.taskTag_findTheRoot, + TaskTag.reduction => loc.taskTag_reduction, + TaskTag.sealIn => loc.taskTag_sealIn, + TaskTag.vitalPointForBothSides => loc.taskTag_vitalPointForBothSides, + TaskTag.basicLifeAndDeath => loc.taskTag_basicLifeAndDeath, + TaskTag.straightThree => loc.taskTag_straightThree, + TaskTag.bentThree => loc.taskTag_bentThree, + TaskTag.bulkyFive => loc.taskTag_bulkyFive, + TaskTag.crossedFive => loc.taskTag_crossedFive, + TaskTag.flowerSix => loc.taskTag_flowerSix, + TaskTag.increaseEyeSpace => loc.taskTag_increaseEyeSpace, + TaskTag.threeEyesTwoActions => loc.taskTag_threeEyesTwoActions, + TaskTag.reduceEyeSpace => loc.taskTag_reduceEyeSpace, + TaskTag.insideMoves => loc.taskTag_insideMoves, + TaskTag.preventBambooJoint => loc.taskTag_preventBambooJoint, + TaskTag.commonLifeAndDeath => loc.taskTag_commonLifeAndDeath, + TaskTag.secondLine => loc.taskTag_secondLine, + TaskTag.makeEyeInOneStep => loc.taskTag_makeEyeInOneStep, + TaskTag.breakEyeInOneStep => loc.taskTag_breakEyeInOneStep, + TaskTag.basicTesuji => loc.taskTag_basicTesuji, + TaskTag.basicEndgame => loc.taskTag_basicEndgame, + TaskTag.doubleSenteEndgame => loc.taskTag_doubleSenteEndgame, + TaskTag.breakPoints => loc.taskTag_breakPoints, + TaskTag.defendPoints => loc.taskTag_defendPoints, + TaskTag.opening => loc.taskTag_opening, + TaskTag.influenceKeyPoints => loc.taskTag_influenceKeyPoints, + TaskTag.bigPoints => loc.taskTag_bigPoints, + TaskTag.urgentPoints => loc.taskTag_urgentPoints, + TaskTag.twoSpaceExtension => loc.taskTag_twoSpaceExtension, + TaskTag.pincer => loc.taskTag_pincer, + TaskTag.comprehensiveTasks => loc.taskTag_comprehensiveTasks, + TaskTag.groupLiberties => loc.taskTag_groupLiberties, + TaskTag.endgame => loc.taskTag_endgame, + TaskTag.smallBoardEndgame => loc.taskTag_smallBoardEndgame, + TaskTag.orderOfMoves => loc.taskTag_orderOfMoves, + TaskTag.orderOfEndgameMoves => loc.taskTag_orderOfEndgameMoves, + TaskTag.useShortageOfLiberties => loc.taskTag_useShortageOfLiberties, + TaskTag.exploitShapeWeakness => loc.taskTag_exploitShapeWeakness, + TaskTag.firstLineBrilliantMove => loc.taskTag_firstLineBrilliantMove, + TaskTag.makeEyeInSente => loc.taskTag_makeEyeInSente, + TaskTag.breakEyeInSente => loc.taskTag_breakEyeInSente, + TaskTag.corner => loc.taskTag_corner, + TaskTag.side => loc.taskTag_side, + TaskTag.makeKo => loc.taskTag_makeKo, + TaskTag.avoidKo => loc.taskTag_avoidKo, + TaskTag.killAfterCapture => loc.taskTag_killAfterCapture, + TaskTag.combination => loc.taskTag_combination, + TaskTag.createShortageOfLiberties => + loc.taskTag_createShortageOfLiberties, + TaskTag.useSurroundingStones => loc.taskTag_useSurroundingStones, + TaskTag.useCornerSpecialProperties => + loc.taskTag_useCornerSpecialProperties, + TaskTag.oneStoneTwoPurposes => loc.taskTag_oneStoneTwoPurposes, + TaskTag.useSnapback => loc.taskTag_useSnapback, + TaskTag.vitalPointForLife => loc.taskTag_vitalPointForLife, + TaskTag.lookForLeverage => loc.taskTag_lookForLeverage, + TaskTag.vitalPointForKill => loc.taskTag_vitalPointForKill, + TaskTag.useConnectAndDie => loc.taskTag_useConnectAndDie, + TaskTag.doNotUnderestimateOpponent => + loc.taskTag_doNotUnderestimateOpponent, + TaskTag.typesOfKo => loc.taskTag_typesOfKo, + TaskTag.captureToLive => loc.taskTag_captureToLive, + TaskTag.avoidMakingDeadShape => loc.taskTag_avoidMakingDeadShape, + TaskTag.defendFromInvasion => loc.taskTag_defendFromInvasion, + TaskTag.indirectAttack => loc.taskTag_indirectAttack, + TaskTag.vitalPointForCapturingRace => + loc.taskTag_vitalPointForCapturingRace, + TaskTag.largeKnightsMove => loc.taskTag_largeKnightsMove, + TaskTag.keepSente => loc.taskTag_keepSente, + TaskTag.doubleSnapback => loc.taskTag_doubleSnapback, + TaskTag.killByEyePointPlacement => loc.taskTag_killByEyePointPlacement, + TaskTag.push => loc.taskTag_push, + TaskTag.bridgeUnder => loc.taskTag_bridgeUnder, TaskTag.techniqueForSecuringTerritory => - "Technique for securing territory", - TaskTag.defendWeakPoint => "Defend weak point", - TaskTag.capturingTechniques => "Capturing techniques", - TaskTag.probe => "Probe", - TaskTag.useDescentToFirstLine => "Use descent to first line", - TaskTag.shapesVitalPoint => "Shape's vital point", - TaskTag.doubleCapture => "Double capture", - TaskTag.preventBridgingUnder => "Prevent bridging under", - TaskTag.runWeakGroup => "Run weak group", - TaskTag.useOpponentsLifeAndDeath => "Use opponent's life and death", - TaskTag.avoidTrap => "Avoid trap", - TaskTag.orderOfMovesInKo => "Order of moves in a ko", - TaskTag.squeeze => "Squeeze", - TaskTag.compositeProblems => "Composite tasks", + loc.taskTag_techniqueForSecuringTerritory, + TaskTag.defendWeakPoint => loc.taskTag_defendWeakPoint, + TaskTag.capturingTechniques => loc.taskTag_capturingTechniques, + TaskTag.probe => loc.taskTag_probe, + TaskTag.useDescentToFirstLine => loc.taskTag_useDescentToFirstLine, + TaskTag.shapesVitalPoint => loc.taskTag_shapesVitalPoint, + TaskTag.doubleCapture => loc.taskTag_doubleCapture, + TaskTag.preventBridgingUnder => loc.taskTag_preventBridgingUnder, + TaskTag.runWeakGroup => loc.taskTag_runWeakGroup, + TaskTag.useOpponentsLifeAndDeath => + loc.taskTag_useOpponentsLifeAndDeath, + TaskTag.avoidTrap => loc.taskTag_avoidTrap, + TaskTag.orderOfMovesInKo => loc.taskTag_orderOfMovesInKo, + TaskTag.squeeze => loc.taskTag_squeeze, + TaskTag.compositeProblems => loc.taskTag_compositeProblems, TaskTag.vitalPointForReducingLiberties => - "Vital point for reducing liberties", + loc.taskTag_vitalPointForReducingLiberties, TaskTag.vitalPointForIncreasingLiberties => - "Vital point for increasing liberties", + loc.taskTag_vitalPointForIncreasingLiberties, TaskTag.preventOpponentFromApproaching => - "Prevent opponent from approaching", - TaskTag.crawl => "Crawl", - TaskTag.makeEye => "Make eye", - TaskTag.breakEye => "Break eye", - TaskTag.straightFour => "Straight four", - TaskTag.bentFour => "Bent four", - TaskTag.clampCapture => "Clamp capture", - TaskTag.rectangularSix => "Rectangular six", - TaskTag.vitalAndUselessStones => "Vital and useless stones", - TaskTag.breakOut => "Break out", - TaskTag.interestingTasks => "Interesting tasks", - TaskTag.textbookTasks => "Textbook tasks", - TaskTag.lifeAndDeathOn4x4 => "Life and death on 4x4", - TaskTag.endgameOn4x4 => "Endgame on 4x4", + loc.taskTag_preventOpponentFromApproaching, + TaskTag.crawl => loc.taskTag_crawl, + TaskTag.makeEye => loc.taskTag_makeEye, + TaskTag.breakEye => loc.taskTag_breakEye, + TaskTag.straightFour => loc.taskTag_straightFour, + TaskTag.bentFour => loc.taskTag_bentFour, + TaskTag.clampCapture => loc.taskTag_clampCapture, + TaskTag.rectangularSix => loc.taskTag_rectangularSix, + TaskTag.vitalAndUselessStones => loc.taskTag_vitalAndUselessStones, + TaskTag.breakOut => loc.taskTag_breakOut, + TaskTag.interestingTasks => loc.taskTag_interestingTasks, + TaskTag.textbookTasks => loc.taskTag_textbookTasks, + TaskTag.lifeAndDeathOn4x4 => loc.taskTag_lifeAndDeathOn4x4, + TaskTag.endgameOn4x4 => loc.taskTag_endgameOn4x4, TaskTag.attackAndDefenseOfInvadingStones => - "Attack and defense of invading stones", - TaskTag.contactFightTesuji => "Contact fight tesuji", - TaskTag.moveOut => "Move out", + loc.taskTag_attackAndDefenseOfInvadingStones, + TaskTag.contactFightTesuji => loc.taskTag_contactFightTesuji, + TaskTag.moveOut => loc.taskTag_moveOut, TaskTag.standardCornerAndSideEndgame => - "Standard corner and side endgame", - TaskTag.settleShapeInSente => "Settle shape in sente", - TaskTag.profitInSente => "Profit in sente", - TaskTag.shape => "Shape", - TaskTag.basicMoves => "Basic moves", - TaskTag.attackAndDefenseOfCuts => "Attack and defense of cuts", - TaskTag.invadingTechnique => "Invading technique", - TaskTag.plunderingTechnique => "Plundering technique", - TaskTag.endgameTesuji => "Endgame tesuji", - TaskTag.profitUsingLifeAndDeath => "Profit using life and death", + loc.taskTag_standardCornerAndSideEndgame, + TaskTag.settleShapeInSente => loc.taskTag_settleShapeInSente, + TaskTag.profitInSente => loc.taskTag_profitInSente, + TaskTag.shape => loc.taskTag_shape, + TaskTag.basicMoves => loc.taskTag_basicMoves, + TaskTag.attackAndDefenseOfCuts => loc.taskTag_attackAndDefenseOfCuts, + TaskTag.invadingTechnique => loc.taskTag_invadingTechnique, + TaskTag.plunderingTechnique => loc.taskTag_plunderingTechnique, + TaskTag.endgameTesuji => loc.taskTag_endgameTesuji, + TaskTag.profitUsingLifeAndDeath => loc.taskTag_profitUsingLifeAndDeath, TaskTag.techniqueForReinforcingGroups => - "Technique for reinforcing groups", - TaskTag.compareSize => "Compare size", - TaskTag.endgameFundamentals => "Endgame fundamentals", - TaskTag.senteAndGote => "Sente and gote", - TaskTag.fillNeutralPoints => "Fill neutral points", - TaskTag.observeSubtleDifference => "Observe subtle difference", - TaskTag.endgameIn5x5 => "Endgame in 5x5", + loc.taskTag_techniqueForReinforcingGroups, + TaskTag.compareSize => loc.taskTag_compareSize, + TaskTag.endgameFundamentals => loc.taskTag_endgameFundamentals, + TaskTag.senteAndGote => loc.taskTag_senteAndGote, + TaskTag.fillNeutralPoints => loc.taskTag_fillNeutralPoints, + TaskTag.observeSubtleDifference => loc.taskTag_observeSubtleDifference, + TaskTag.endgameIn5x5 => loc.taskTag_endgameIn5x5, TaskTag.completeKoToSecureEndgameAdvantage => - "Complete ko to secure endgame advantage", - TaskTag.groupsBase => "Group's base", - TaskTag.openingChoice => "Opening choice", - TaskTag.openingFundamentals => "Opening fundamentals", + loc.taskTag_completeKoToSecureEndgameAdvantage, + TaskTag.groupsBase => loc.taskTag_groupsBase, + TaskTag.openingChoice => loc.taskTag_openingChoice, + TaskTag.openingFundamentals => loc.taskTag_openingFundamentals, TaskTag.cornerIsGoldSideIsSilverCenterIsGrass => - "Corner is gold, side is silver, center is grass", - TaskTag.moveTowardsEmptySpace => "Move towards empty space", - TaskTag.thirdAndFourthLine => "Third and fourth line", - TaskTag.directionOfPlay => "Direction of play", - TaskTag.compareValue => "Compare value", + loc.taskTag_cornerIsGoldSideIsSilverCenterIsGrass, + TaskTag.moveTowardsEmptySpace => loc.taskTag_moveTowardsEmptySpace, + TaskTag.thirdAndFourthLine => loc.taskTag_thirdAndFourthLine, + TaskTag.directionOfPlay => loc.taskTag_directionOfPlay, + TaskTag.compareValue => loc.taskTag_compareValue, TaskTag.occupyEncloseAndApproachCorner => - "Occupy, enclose and approach corners", - TaskTag.josekiFundamentals => "Joseki fundamentals", - TaskTag.afterJoseki => "After joseki", - TaskTag.useInfluence => "Use influence", - TaskTag.aiOpening => "AI opening", - TaskTag.aiVariations => "AI variations", - TaskTag.counterAttack => "Counter-attack", - TaskTag.breakShape => "Break shape", - TaskTag.counter => "Counter", - TaskTag.makeShape => "Make shape", - TaskTag.bigEyeLiberties => "Big eye's liberties", - TaskTag.standardCapturingRaces => "Standard capturing races", - TaskTag.realEyeAndFalseEye => "Real eye vs false eye", - TaskTag.surroundTerritory => "Surround territory", - TaskTag.urgentPointOfAFight => "Urgent point of a fight", - TaskTag.seizeTheOpportunity => "Seize the opportunity", - TaskTag.chooseTheFight => "Choose the fight", - TaskTag.largeMoyoFight => "Large moyo fight", + loc.taskTag_occupyEncloseAndApproachCorner, + TaskTag.josekiFundamentals => loc.taskTag_josekiFundamentals, + TaskTag.afterJoseki => loc.taskTag_afterJoseki, + TaskTag.useInfluence => loc.taskTag_useInfluence, + TaskTag.aiOpening => loc.taskTag_aiOpening, + TaskTag.aiVariations => loc.taskTag_aiVariations, + TaskTag.counterAttack => loc.taskTag_counterAttack, + TaskTag.breakShape => loc.taskTag_breakShape, + TaskTag.counter => loc.taskTag_counter, + TaskTag.makeShape => loc.taskTag_makeShape, + TaskTag.bigEyeLiberties => loc.taskTag_bigEyeLiberties, + TaskTag.standardCapturingRaces => loc.taskTag_standardCapturingRaces, + TaskTag.realEyeAndFalseEye => loc.taskTag_realEyeAndFalseEye, + TaskTag.surroundTerritory => loc.taskTag_surroundTerritory, + TaskTag.urgentPointOfAFight => loc.taskTag_urgentPointOfAFight, + TaskTag.seizeTheOpportunity => loc.taskTag_seizeTheOpportunity, + TaskTag.chooseTheFight => loc.taskTag_chooseTheFight, + TaskTag.largeMoyoFight => loc.taskTag_largeMoyoFight, TaskTag.threeSpaceExtensionFromTwoStones => - "Three-space extension from two stones", - TaskTag.sabakiAndUtilizingInfluence => "Sabaki and utilizing influence", - TaskTag.squareFour => "Square four", - TaskTag.monkeyClimbingMountain => "Monkey climbing the mountain", - TaskTag.attackAndDefenseInKo => "Attack and defense in a ko", - TaskTag.splittingMove => "Splitting move", + loc.taskTag_threeSpaceExtensionFromTwoStones, + TaskTag.sabakiAndUtilizingInfluence => + loc.taskTag_sabakiAndUtilizingInfluence, + TaskTag.squareFour => loc.taskTag_squareFour, + TaskTag.monkeyClimbingMountain => loc.taskTag_monkeyClimbingMountain, + TaskTag.attackAndDefenseInKo => loc.taskTag_attackAndDefenseInKo, + TaskTag.splittingMove => loc.taskTag_splittingMove, }; IList ranks() => switch (this) { @@ -1937,7 +1944,7 @@ enum TaskTag { TaskTag.jGroupAndSimilar => IList([ RankRange(from: Rank.k7, to: Rank.k4), RankRange(from: Rank.k3, to: Rank.k1), - RankRange(from: Rank.d1, to: Rank.k3), + RankRange(from: Rank.k3, to: Rank.d1), RankRange(from: Rank.d4, to: Rank.d6), ]), TaskTag.josekiFundamentals => IList([ diff --git a/lib/train/task_type.dart b/lib/train/task_type.dart index c270564f..2fa8be96 100644 --- a/lib/train/task_type.dart +++ b/lib/train/task_type.dart @@ -1,3 +1,5 @@ +import 'package:wqhub/l10n/app_localizations.dart'; + enum TaskType { lifeAndDeath, tesuji, @@ -10,17 +12,16 @@ enum TaskType { theory, appreciation; - @override - String toString() => switch (this) { - TaskType.lifeAndDeath => 'Life & death', - TaskType.tesuji => 'Tesuji', - TaskType.capture => 'Capture stones', - TaskType.captureRace => 'Capture race', - TaskType.opening => 'Opening', - TaskType.joseki => 'Joseki', - TaskType.middlegame => 'Middlegame', - TaskType.endgame => 'Endgame', - TaskType.theory => 'Theory', - TaskType.appreciation => 'Appreciation' + String toLocalizedString(AppLocalizations loc) => switch (this) { + TaskType.lifeAndDeath => loc.taskTypeLifeAndDeath, + TaskType.tesuji => loc.taskTypeTesuji, + TaskType.capture => loc.taskTypeCapture, + TaskType.captureRace => loc.taskTypeCaptureRace, + TaskType.opening => loc.taskTypeOpening, + TaskType.joseki => loc.taskTypeJoseki, + TaskType.middlegame => loc.taskTypeMiddlegame, + TaskType.endgame => loc.taskTypeEndgame, + TaskType.theory => loc.taskTypeTheory, + TaskType.appreciation => loc.taskTypeAppreciation, }; } diff --git a/lib/train/time_frenzy_page.dart b/lib/train/time_frenzy_page.dart index 6b5ddc5c..768536f1 100644 --- a/lib/train/time_frenzy_page.dart +++ b/lib/train/time_frenzy_page.dart @@ -1,14 +1,14 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:wqhub/audio/audio_controller.dart'; import 'package:wqhub/game_client/time_state.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; import 'package:wqhub/time_display.dart'; import 'package:wqhub/confirm_dialog.dart'; -import 'package:wqhub/train/solve_status_notifier.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_solving_state_mixin.dart'; import 'package:wqhub/turn_icon.dart'; -import 'package:wqhub/wq/annotated_game_tree.dart'; import 'package:wqhub/board/board.dart'; import 'package:wqhub/board/board_settings.dart'; import 'package:wqhub/board/coordinate_style.dart'; @@ -17,7 +17,15 @@ import 'package:wqhub/train/variation_tree.dart'; import 'package:wqhub/wq/rank.dart'; import 'package:wqhub/wq/wq.dart' as wq; +class TimeFrenzyRouteArguments { + final TaskSource taskSource; + + const TimeFrenzyRouteArguments({required this.taskSource}); +} + class TimeFrenzyPage extends StatefulWidget { + static const routeName = '/train/time_frenzy'; + const TimeFrenzyPage({super.key, required this.taskSource}); final TaskSource taskSource; @@ -29,12 +37,9 @@ class TimeFrenzyPage extends StatefulWidget { const _sessionLength = Duration(minutes: 3); class _TimeFrenzyPageState extends State - with SolveStatusNotifier { + with TaskSolvingStateMixin { final _timeDisplayKey = GlobalKey(debugLabel: 'time-display'); final _stopwatch = Stopwatch(); - VariationTreeIterator? _vtreeIt; - var _gameTree = AnnotatedGameTree(19); - var _turn = wq.Color.black; var _taskNumber = 1; var _mistakeCount = 0; var _solveCount = 0; @@ -43,7 +48,6 @@ class _TimeFrenzyPageState extends State @override void initState() { super.initState(); - _restartCurrentTask(); _stopwatch.start(); } @@ -55,6 +59,7 @@ class _TimeFrenzyPageState extends State @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final wideLayout = MediaQuery.sizeOf(context).aspectRatio > 1.5; final borderSize = @@ -92,17 +97,17 @@ class _TimeFrenzyPageState extends State return Board( size: boardSize, settings: boardSettings, - onPointClicked: (p) => _onPointClicked(p, wideLayout), - turn: _turn, - stones: _gameTree.stones, - annotations: _gameTree.annotations, + onPointClicked: (p) => onMove(p, wideLayout), + turn: turn, + stones: gameTree.stones, + annotations: gameTree.annotations, confirmTap: context.settings.confirmMoves, ); }, ); final taskTitle = - '[${widget.taskSource.task.rank.toString()}] ${widget.taskSource.task.type.toString()}'; + '[${widget.taskSource.task.rank.toString()}] ${widget.taskSource.task.type.toLocalizedString(loc)}'; final timeDisplay = TimeDisplay( key: _timeDisplayKey, @@ -153,9 +158,8 @@ class _TimeFrenzyPageState extends State showDialog( context: context, builder: (context) => ConfirmDialog( - title: 'Confirm', - content: - 'Are you sure that you want to stop the Time Frenzy?', + title: loc.confirm, + content: loc.msgConfirmStopEvent(loc.timeFrenzy), onYes: () => Navigator.popUntil(context, (route) => route.isFirst), onNo: () => Navigator.pop(context), @@ -175,41 +179,15 @@ class _TimeFrenzyPageState extends State } } - void _onPointClicked(wq.Point p, bool wideLayout) { - if (_gameTree.moveAnnotated((col: _turn, p: p), - mode: AnnotationMode.variation) != - null) { - if (context.settings.sound) { - AudioController().playForNode(_gameTree.curNode); - } - final status = _vtreeIt!.move(p); - _turn = _turn.opposite; - if (status != null) { - _setSolveStatus(status, wideLayout); - } else { - final resp = _vtreeIt!.genMove(); - _gameTree.moveAnnotated((col: _turn, p: resp), - mode: AnnotationMode.variation); - _turn = _turn.opposite; - final status = _vtreeIt!.move(resp); - if (status != null) { - _setSolveStatus(status, wideLayout); - } - } - setState(() {/* Update board */}); - } - } + @override + Task get currentTask => widget.taskSource.task; - void _setSolveStatus(VariationStatus status, bool wideLayout) { - notifySolveStatus(status, wideLayout); + @override + void onSolveStatus(VariationStatus status) { if (status == VariationStatus.correct) { - if (context.settings.sound) AudioController().correct(); _solveCount++; - context.stats.incrementTotalPassCount(widget.taskSource.task.rank); } else { - if (context.settings.sound) AudioController().wrong(); _mistakeCount++; - context.stats.incrementTotalFailCount(widget.taskSource.task.rank); if (_mistakeCount == 3) { _endRun(); return; @@ -220,8 +198,9 @@ class _TimeFrenzyPageState extends State if (widget.taskSource.task.rank.index > _maxRank.index) { _maxRank = widget.taskSource.task.rank; } - _restartCurrentTask(); + setupCurrentTask(); _stopwatch.reset(); + solveStatus = null; } void _endRun() { @@ -236,19 +215,6 @@ class _TimeFrenzyPageState extends State context.stats.updateTimeFrenzyHighScore(_solveCount); setState(() {/* Stop the timer */}); } - - void _restartCurrentTask() { - _vtreeIt = - VariationTreeIterator(tree: widget.taskSource.task.variationTree); - _gameTree = AnnotatedGameTree(widget.taskSource.task.boardSize); - for (final entry in widget.taskSource.task.initialStones.entries) { - for (final p in entry.value) { - _gameTree - .moveAnnotated((col: entry.key, p: p), mode: AnnotationMode.none); - } - } - _turn = widget.taskSource.task.first; - } } class _SideBar extends StatelessWidget { @@ -265,6 +231,7 @@ class _SideBar extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; final widgetSize = MediaQuery.sizeOf(context); final sidebarSize = min( widgetSize.longestSide - widgetSize.shortestSide, widgetSize.width / 3); @@ -288,9 +255,8 @@ class _SideBar extends StatelessWidget { showDialog( context: context, builder: (context) => ConfirmDialog( - title: 'Confirm', - content: - 'Are you sure that you want to stop the Time Frenzy?', + title: loc.confirm, + content: loc.msgConfirmStopEvent(loc.timeFrenzy), onYes: () => Navigator.popUntil( context, (route) => route.isFirst), onNo: () => Navigator.pop(context), @@ -325,24 +291,25 @@ class _ResultDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return AlertDialog( - title: const Text('Result'), + title: Text(loc.result), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( - title: const Text('Tasks solved'), + title: Text(loc.tasksSolved), trailing: Text('$solveCount'), ), ListTile( - title: const Text('Max rank'), + title: Text(loc.maxRank), trailing: Text(maxRank.toString()), ), ], ), actions: [ TextButton( - child: const Text('OK'), + child: Text(loc.ok), onPressed: () { Navigator.popUntil(context, (route) => route.isFirst); }, diff --git a/lib/train/train_stats_page.dart b/lib/train/train_stats_page.dart index e4f9bb0c..ac7c723a 100644 --- a/lib/train/train_stats_page.dart +++ b/lib/train/train_stats_page.dart @@ -1,36 +1,183 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; +import 'package:wqhub/stats/stats_db.dart'; import 'package:wqhub/train/grading_exam_ranks.dart'; import 'package:wqhub/wq/rank.dart'; -class TrainStatsPage extends StatefulWidget { +class TrainStatsPage extends StatelessWidget { + static const routeName = '/train/stats'; + static final _onlyTime = DateFormat('HH:mm'); + static final _onlyDate = DateFormat('yyyy.MM.dd'); + const TrainStatsPage({super.key}); @override - State createState() => _TrainStatsPageState(); + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final thisWeek = today.subtract(Duration(days: today.weekday)); + final thisMonth = DateTime(now.year, now.month); + return DefaultTabController( + initialIndex: 0, + length: 4, + child: Scaffold( + appBar: AppBar( + title: Text(loc.statistics), + bottom: TabBar( + tabs: [ + Tab(icon: Icon(Icons.today), text: loc.today), + Tab(icon: Icon(Icons.calendar_view_week), text: loc.week), + Tab(icon: Icon(Icons.calendar_month), text: loc.month), + Tab(icon: Icon(Icons.bar_chart), text: loc.byRank), + ], + ), + ), + body: TabBarView( + children: [ + _TimePeriodTab( + dateLabel: loc.statsTimeColumn, + dateFormat: _onlyTime, + since: today), + _TimePeriodTab( + dateLabel: loc.statsDateColumn, + dateFormat: _onlyDate, + since: thisWeek), + _TimePeriodTab( + dateLabel: loc.statsDateColumn, + dateFormat: _onlyDate, + since: thisMonth), + _ByRankTab(), + ], + ), + ), + ); + } } -class _TrainStatsPageState extends State { +class _TimePeriodTab extends StatefulWidget { + final String dateLabel; + final DateFormat dateFormat; + final DateTime since; + + const _TimePeriodTab({ + required this.dateLabel, + required this.dateFormat, + required this.since, + }); + + @override + State<_TimePeriodTab> createState() => _TimePeriodTabState(); +} + +class _TimePeriodTabState extends State<_TimePeriodTab> { + late final dailyStatsFut = + Future(() => StatsDB().taskDailyStatsSince(widget.since)); + late final examEntriesFut = + Future(() => StatsDB().examsSince(widget.since).reversed); + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Statistics'), - ), - body: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 800), - child: SingleChildScrollView( - child: Column( - children: [ - for (final rank in gradingExamRanks) - _RankTotalIndicator(rank: rank) - ], + final loc = AppLocalizations.of(context)!; + final isCompact = MediaQuery.sizeOf(context).width < 600; + final passedIcon = Icon(Icons.check, color: Colors.green); + final failedIcon = Icon(Icons.close, color: Colors.red); + final taskDailyStatsText = FutureBuilder( + future: dailyStatsFut, + builder: (context, snapshot) { + if (snapshot.hasData) { + final (correctCount, wrongCount) = snapshot.data!; + final totalCount = correctCount + wrongCount; + return Text( + '$correctCount / $totalCount (${(100 * correctCount / max(totalCount, 1)).floor()}%)', + style: TextTheme.of(context).titleLarge); + } + return CircularProgressIndicator(); + }); + final examTable = FutureBuilder( + future: examEntriesFut, + builder: (context, snapshot) { + if (snapshot.hasData) { + final entries = snapshot.data!; + return Expanded( + child: SingleChildScrollView( + child: DataTable( + columnSpacing: 8, + columns: [ + DataColumn(label: Text(widget.dateLabel)), + DataColumn(label: Text(loc.type)), + DataColumn(label: Text(loc.rank)), + DataColumn(label: Text(loc.result)), + if (!isCompact) + DataColumn(label: Text(loc.statsDurationColumn)), + ], + rows: [ + for (final entry in entries) + DataRow( + cells: [ + DataCell(Text(widget.dateFormat.format(entry.date))), + DataCell(Text(entry.event.toLocalizedString(loc), + softWrap: false, overflow: TextOverflow.fade)), + DataCell(Text(entry.rankRange.toString())), + DataCell( + Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + (entry.passed ? passedIcon : failedIcon), + Text( + '${entry.correctCount} / ${entry.correctCount + entry.wrongCount}'), + ], + ), + ), + if (!isCompact) + DataCell(Text('${entry.duration.inSeconds}s')), + ], + ) + ], + ), + ), + ); + } + return Center(child: CircularProgressIndicator()); + }, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8.0, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: taskDailyStatsText, ), ), ), + examTable, + ], + ); + } +} + +class _ByRankTab extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 800), + child: SingleChildScrollView( + child: Column( + children: [ + for (final rank in gradingExamRanks) + _RankTotalIndicator(rank: rank) + ], + ), + ), ), ); } diff --git a/lib/train/upsolve_mode.dart b/lib/train/upsolve_mode.dart new file mode 100644 index 00000000..b676bb53 --- /dev/null +++ b/lib/train/upsolve_mode.dart @@ -0,0 +1,4 @@ +enum UpsolveMode { + auto, + manual; +} diff --git a/lib/train/variation_tree.dart b/lib/train/variation_tree.dart index 4ba752be..6cbd0d6a 100644 --- a/lib/train/variation_tree.dart +++ b/lib/train/variation_tree.dart @@ -1,16 +1,16 @@ import 'dart:math'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/wq/wq.dart' as wq; enum VariationStatus { correct, wrong; - @override - String toString() => switch (this) { - VariationStatus.correct => 'Correct', - VariationStatus.wrong => 'Wrong', + String toLocalizedString(AppLocalizations loc) => switch (this) { + VariationStatus.correct => loc.taskCorrect, + VariationStatus.wrong => loc.taskWrong, }; } @@ -30,25 +30,59 @@ class VariationTree { } class VariationTreeIterator { - VariationTree _tree; + final _trees = []; + final _moves = []; + var _cur = 0; - VariationTreeIterator({required tree}) : _tree = tree; + VariationTreeIterator({required tree}) { + _trees.add(tree); + } + + int get depth => _cur; + + VariationTree? get tree => _trees[_cur]; VariationStatus? move(wq.Point p) { - final next = _tree.children[p]; - if (next == null) return VariationStatus.wrong; + if (_cur < _moves.length) { + if (_moves[_cur] == p) { + _cur++; + } else { + while (_cur < _moves.length) { + _trees.removeLast(); + _moves.removeLast(); + } + _trees.add(tree?.children[p]); + _moves.add(p); + _cur++; + } + } else { + _trees.add(tree?.children[p]); + _moves.add(p); + _cur++; + } + return (tree != null) ? tree!.status : VariationStatus.wrong; + } + + void undo() { + _cur--; + } - _tree = next; - return _tree.status; + wq.Point? redo() { + if (_cur >= _moves.length) return null; + final p = _moves[_cur]; + _cur++; + return p; } wq.Point genMove() { - assert(_tree.children.isNotEmpty); - return _tree.children.keys - .elementAt(Random().nextInt(_tree.children.length)); + assert(tree!.children.isNotEmpty); + return tree!.children.keys + .elementAt(Random().nextInt(tree!.children.length)); } - List<(wq.Point, VariationStatus)> continuations() => _tree.children.entries - .map((e) => (e.key, e.value.finalStatus())) - .toList(); + List<(wq.Point, VariationStatus)> continuations() => + tree?.children.entries + .map((e) => (e.key, e.value.finalStatus())) + .toList() ?? + List.empty(); } diff --git a/lib/version_patch.dart b/lib/version_patch.dart new file mode 100644 index 00000000..79a74743 --- /dev/null +++ b/lib/version_patch.dart @@ -0,0 +1,2111 @@ +import 'dart:developer'; + +import 'package:wqhub/settings/settings.dart'; +import 'package:wqhub/stats/stats_db.dart'; +import 'package:wqhub/train/task_repository.dart'; + +void applyVersionPatch(Settings settings) { + _versionPatch_0_1_8(settings); + _versionPatch_0_1_9(settings); +} + +void _versionPatch_0_1_8(Settings settings) { + const version = '0.1.8'; + if (settings.getVersionPatchStatus(version)) { + log('skipping version patch: $version'); + return; + } + log('applying version patch: $version'); + + StatsDB().deleteMistakes([ + 'wqhub://t/100100026138', + 'wqhub://t/1200000028e9', + 'wqhub://t/12010001902b', + 'wqhub://t/120200017387', + 'wqhub://t/1300000192da', + 'wqhub://t/13000002403a', + 'wqhub://t/130200026208', + 'wqhub://t/1302000263a9', + 'wqhub://t/140200007a9a', + 'wqhub://t/1500000041b8', + 'wqhub://t/150000008ff8', + 'wqhub://t/15020001b6ea', + 'wqhub://t/16000000651d', + 'wqhub://t/16000000aa1c', + 'wqhub://t/1603000015e6', + 'wqhub://t/17000000acc7', + 'wqhub://t/18000000650f', + 'wqhub://t/1800000065a6', + 'wqhub://t/18000001fc59', + 'wqhub://t/18070001c505', + 'wqhub://t/190000000edf', + 'wqhub://t/190000006537', + 'wqhub://t/19000000dfca', + 'wqhub://t/190100007e42', + 'wqhub://t/1a00000064c4', + 'wqhub://t/1a000000acb2', + 'wqhub://t/1a000000c28c', + 'wqhub://t/1a000000c4c0', + 'wqhub://t/1a000000f807', + 'wqhub://t/1a0000014593', + 'wqhub://t/1a0000015d1b', + 'wqhub://t/1a000001aedb', + 'wqhub://t/1a000001e9ac', + 'wqhub://t/1a01000064bd', + 'wqhub://t/1a01000070ab', + 'wqhub://t/1a010000968e', + 'wqhub://t/1a010000ee93', + 'wqhub://t/1a010001f5a4', + 'wqhub://t/1a0300003c0f', + 'wqhub://t/1a070001ef1f', + 'wqhub://t/1b0000000578', + 'wqhub://t/1b0000000e32', + 'wqhub://t/1b000000cc02', + 'wqhub://t/1b000000f6b8', + 'wqhub://t/1b0000021a1c', + 'wqhub://t/1b010000ed9a', + 'wqhub://t/1b0300000ff7', + 'wqhub://t/1b03000182c0', + 'wqhub://t/1b03000253ae', + 'wqhub://t/1c0000000e71', + 'wqhub://t/1c0000014318', + 'wqhub://t/1c000001c0bf', + 'wqhub://t/1c000001f2d8', + 'wqhub://t/1c0100004f20', + 'wqhub://t/1d010001d45e', + 'wqhub://t/200000010305', + 'wqhub://t/2107000057c5', + ].map((u) => TaskRef.ofUri(u))); + + settings.setVersionPatchStatus(version, true); +} + +void _versionPatch_0_1_9(Settings settings) { + const version = '0.1.9'; + if (settings.getVersionPatchStatus(version)) { + log('skipping version patch: $version'); + return; + } + log('applying version patch: $version'); + + StatsDB().deleteMistakes([ + "wqhub://t/0f020002484f", + "wqhub://t/100200020572", + "wqhub://t/1002000205ab", + "wqhub://t/1002000235af", + "wqhub://t/100200024d15", + "wqhub://t/100200025ca5", + "wqhub://t/100200025fb3", + "wqhub://t/1002000268ce", + "wqhub://t/10030001e294", + "wqhub://t/11000001fcc2", + "wqhub://t/11020001304f", + "wqhub://t/110200027604", + "wqhub://t/120000001687", + "wqhub://t/1201000219ef", + "wqhub://t/130000006525", + "wqhub://t/130000006592", + "wqhub://t/130000015556", + "wqhub://t/13000001eb73", + "wqhub://t/13010000188e", + "wqhub://t/13010001fc97", + "wqhub://t/130200011270", + "wqhub://t/130300018075", + "wqhub://t/13090000596f", + "wqhub://t/140000002f00", + "wqhub://t/1400000042cb", + "wqhub://t/140000006e12", + "wqhub://t/14000000e943", + "wqhub://t/14000001b823", + "wqhub://t/1400000220e1", + "wqhub://t/1400000238ef", + "wqhub://t/1401000065a3", + "wqhub://t/140200023fc2", + "wqhub://t/1403000124e9", + "wqhub://t/150000006560", + "wqhub://t/15000000657c", + "wqhub://t/1500000065a4", + "wqhub://t/15000000ddd0", + "wqhub://t/15000001a8a0", + "wqhub://t/15000001fa64", + "wqhub://t/15010000095f", + "wqhub://t/15010001902e", + "wqhub://t/150100022627", + "wqhub://t/15010002283d", + "wqhub://t/1502000081f3", + "wqhub://t/150300001f07", + "wqhub://t/150900006379", + "wqhub://t/16000000306f", + "wqhub://t/16000000652e", + "wqhub://t/160000006593", + "wqhub://t/1600000065bb", + "wqhub://t/16000000b0e6", + "wqhub://t/1601000064cd", + "wqhub://t/16010001c7e8", + "wqhub://t/16010001c856", + "wqhub://t/16090000e31a", + "wqhub://t/160900011106", + "wqhub://t/1609000205da", + "wqhub://t/170000002920", + "wqhub://t/17000000650e", + "wqhub://t/170000006517", + "wqhub://t/170000006532", + "wqhub://t/170000006550", + "wqhub://t/17000000658e", + "wqhub://t/1700000065a8", + "wqhub://t/170000009ded", + "wqhub://t/17000000dc23", + "wqhub://t/170000012bd6", + "wqhub://t/17000001c5e3", + "wqhub://t/17000001e560", + "wqhub://t/17000001e811", + "wqhub://t/17000001e8f3", + "wqhub://t/17000001f631", + "wqhub://t/170000022803", + "wqhub://t/170000022b38", + "wqhub://t/170000023042", + "wqhub://t/170000024520", + "wqhub://t/1701000045a4", + "wqhub://t/1701000064e3", + "wqhub://t/17010000b6a8", + "wqhub://t/17010000fb17", + "wqhub://t/170100021a07", + "wqhub://t/17030001d88e", + "wqhub://t/170700002f14", + "wqhub://t/17070001bb48", + "wqhub://t/170700023bc9", + "wqhub://t/17090000a6e2", + "wqhub://t/170900012267", + "wqhub://t/1709000162dc", + "wqhub://t/170900023b46", + "wqhub://t/170900023fbd", + "wqhub://t/180000000cf6", + "wqhub://t/180000000ffb", + "wqhub://t/180000003b63", + "wqhub://t/180000003c21", + "wqhub://t/18000000472c", + "wqhub://t/180000004ce4", + "wqhub://t/180000006186", + "wqhub://t/180000006504", + "wqhub://t/18000000650c", + "wqhub://t/180000006520", + "wqhub://t/180000006528", + "wqhub://t/18000000653c", + "wqhub://t/1800000065b5", + "wqhub://t/18000000ca70", + "wqhub://t/18000000ed53", + "wqhub://t/180000017335", + "wqhub://t/18000001c1cb", + "wqhub://t/18000001f0ff", + "wqhub://t/18000001f169", + "wqhub://t/180000022539", + "wqhub://t/18000002299b", + "wqhub://t/1800000275d1", + "wqhub://t/180100001280", + "wqhub://t/180100003ab4", + "wqhub://t/1801000064c2", + "wqhub://t/1801000064ca", + "wqhub://t/1801000064d9", + "wqhub://t/18010001adb4", + "wqhub://t/18010001c357", + "wqhub://t/18010001f51c", + "wqhub://t/18010001faad", + "wqhub://t/1803000007b1", + "wqhub://t/18030001f75f", + "wqhub://t/180300021a20", + "wqhub://t/180500006493", + "wqhub://t/180500008c92", + "wqhub://t/180900002966", + "wqhub://t/18090000c868", + "wqhub://t/18090001f217", + "wqhub://t/190000004096", + "wqhub://t/19000000654e", + "wqhub://t/19000000655b", + "wqhub://t/19000000656b", + "wqhub://t/190000006587", + "wqhub://t/190000008023", + "wqhub://t/190000009e35", + "wqhub://t/19000000a99f", + "wqhub://t/19000000b183", + "wqhub://t/19000000b612", + "wqhub://t/19000000beb3", + "wqhub://t/19000000c96e", + "wqhub://t/19000000fc73", + "wqhub://t/190000010e37", + "wqhub://t/190000014353", + "wqhub://t/190000015748", + "wqhub://t/190000016771", + "wqhub://t/19000001bce1", + "wqhub://t/19000001c5b8", + "wqhub://t/19000001f08e", + "wqhub://t/19000001fe96", + "wqhub://t/19000001ff6a", + "wqhub://t/190000020436", + "wqhub://t/190000023b70", + "wqhub://t/190000023cd6", + "wqhub://t/1901000003ac", + "wqhub://t/1901000064bc", + "wqhub://t/190100006e15", + "wqhub://t/19010001608c", + "wqhub://t/19010001fb5f", + "wqhub://t/19010001fc22", + "wqhub://t/190100020db1", + "wqhub://t/190100023d41", + "wqhub://t/190300002dca", + "wqhub://t/190300006555", + "wqhub://t/19030001dd01", + "wqhub://t/19030001eb08", + "wqhub://t/19030001ebcb", + "wqhub://t/1903000274d1", + "wqhub://t/190700001753", + "wqhub://t/19070000e93b", + "wqhub://t/190700019c5e", + "wqhub://t/19070001c992", + "wqhub://t/19070001d055", + "wqhub://t/1909000102f5", + "wqhub://t/19090001ff7b", + "wqhub://t/190900024dc0", + "wqhub://t/1a0000000c32", + "wqhub://t/1a0000000e72", + "wqhub://t/1a0000000f1d", + "wqhub://t/1a0000001408", + "wqhub://t/1a0000006409", + "wqhub://t/1a0000006413", + "wqhub://t/1a0000006421", + "wqhub://t/1a0000006422", + "wqhub://t/1a000000653d", + "wqhub://t/1a0000008e7c", + "wqhub://t/1a000000ac3d", + "wqhub://t/1a000000c893", + "wqhub://t/1a000000ee32", + "wqhub://t/1a0000010a57", + "wqhub://t/1a0000016129", + "wqhub://t/1a00000173ed", + "wqhub://t/1a000001790d", + "wqhub://t/1a0000019363", + "wqhub://t/1a000001caa9", + "wqhub://t/1a000001f6ab", + "wqhub://t/1a000001fa97", + "wqhub://t/1a000001fdf6", + "wqhub://t/1a000002040b", + "wqhub://t/1a00000212d9", + "wqhub://t/1a0000021389", + "wqhub://t/1a000002189e", + "wqhub://t/1a0000021a1e", + "wqhub://t/1a00000227b3", + "wqhub://t/1a0000023946", + "wqhub://t/1a0000023a87", + "wqhub://t/1a0000024d44", + "wqhub://t/1a0100004341", + "wqhub://t/1a010001df34", + "wqhub://t/1a010001fa9f", + "wqhub://t/1a0100023afa", + "wqhub://t/1a0300006432", + "wqhub://t/1a030001a852", + "wqhub://t/1a030001adc8", + "wqhub://t/1a070001a4c9", + "wqhub://t/1a070001fa77", + "wqhub://t/1a070002282f", + "wqhub://t/1a0700022d77", + "wqhub://t/1a0700023cc3", + "wqhub://t/1a0900005693", + "wqhub://t/1a0900008d08", + "wqhub://t/1a090000b64f", + "wqhub://t/1a090000c186", + "wqhub://t/1a090000c6e7", + "wqhub://t/1a090001136f", + "wqhub://t/1a0900011398", + "wqhub://t/1a0900021e84", + "wqhub://t/1a0900022c6f", + "wqhub://t/1a0900024e0a", + "wqhub://t/1b00000015e7", + "wqhub://t/1b0000003ec7", + "wqhub://t/1b0000005f0e", + "wqhub://t/1b0000006149", + "wqhub://t/1b000000619d", + "wqhub://t/1b0000006403", + "wqhub://t/1b0000006411", + "wqhub://t/1b000000641d", + "wqhub://t/1b000000642a", + "wqhub://t/1b000000644d", + "wqhub://t/1b00000064fe", + "wqhub://t/1b0000006521", + "wqhub://t/1b0000006545", + "wqhub://t/1b000000c66d", + "wqhub://t/1b000000db3f", + "wqhub://t/1b000000df2e", + "wqhub://t/1b0000010619", + "wqhub://t/1b0000010a43", + "wqhub://t/1b000001181a", + "wqhub://t/1b0000014c82", + "wqhub://t/1b0000014d7b", + "wqhub://t/1b0000016e42", + "wqhub://t/1b0000017781", + "wqhub://t/1b0000018212", + "wqhub://t/1b0000018451", + "wqhub://t/1b000001c0cf", + "wqhub://t/1b000001ccff", + "wqhub://t/1b000001cfe4", + "wqhub://t/1b000001e219", + "wqhub://t/1b000001e409", + "wqhub://t/1b000001eb62", + "wqhub://t/1b000001f2ee", + "wqhub://t/1b0000020072", + "wqhub://t/1b0000021a01", + "wqhub://t/1b0000021c0c", + "wqhub://t/1b00000224ee", + "wqhub://t/1b0000023dcc", + "wqhub://t/1b0000027411", + "wqhub://t/1b01000082ac", + "wqhub://t/1b01000086bb", + "wqhub://t/1b0100008869", + "wqhub://t/1b010000fa70", + "wqhub://t/1b0100017133", + "wqhub://t/1b010001afd1", + "wqhub://t/1b010001b102", + "wqhub://t/1b010001d76c", + "wqhub://t/1b010001dd78", + "wqhub://t/1b010001e7e3", + "wqhub://t/1b01000221ae", + "wqhub://t/1b01000225cc", + "wqhub://t/1b010002468e", + "wqhub://t/1b03000036db", + "wqhub://t/1b0300006596", + "wqhub://t/1b030000ea6b", + "wqhub://t/1b0300016810", + "wqhub://t/1b030001f0c9", + "wqhub://t/1b07000012b0", + "wqhub://t/1b0700005d93", + "wqhub://t/1b070000643e", + "wqhub://t/1b07000064f6", + "wqhub://t/1b07000064fa", + "wqhub://t/1b070000ed50", + "wqhub://t/1b0700019e7a", + "wqhub://t/1b070001c041", + "wqhub://t/1b070001fcee", + "wqhub://t/1b070002036d", + "wqhub://t/1b07000205cb", + "wqhub://t/1b090000836e", + "wqhub://t/1b090000c23b", + "wqhub://t/1b0900012abc", + "wqhub://t/1b0900013776", + "wqhub://t/1b0900015454", + "wqhub://t/1b0900021e12", + "wqhub://t/1b0900021f2d", + "wqhub://t/1b09000253ff", + "wqhub://t/1c000000035c", + "wqhub://t/1c0000006154", + "wqhub://t/1c0000006161", + "wqhub://t/1c0000006190", + "wqhub://t/1c0000006195", + "wqhub://t/1c00000063ff", + "wqhub://t/1c0000006400", + "wqhub://t/1c000000640a", + "wqhub://t/1c0000006412", + "wqhub://t/1c0000006523", + "wqhub://t/1c0000009e74", + "wqhub://t/1c000000ae97", + "wqhub://t/1c000000c613", + "wqhub://t/1c0000010c70", + "wqhub://t/1c0000010f97", + "wqhub://t/1c0000011a85", + "wqhub://t/1c000001278d", + "wqhub://t/1c000001336b", + "wqhub://t/1c0000015d59", + "wqhub://t/1c0000016440", + "wqhub://t/1c000001686b", + "wqhub://t/1c00000174a9", + "wqhub://t/1c0000017ad7", + "wqhub://t/1c00000191aa", + "wqhub://t/1c00000197b5", + "wqhub://t/1c000001be8b", + "wqhub://t/1c000001db40", + "wqhub://t/1c000001e058", + "wqhub://t/1c000001e814", + "wqhub://t/1c000001efe4", + "wqhub://t/1c000001f81f", + "wqhub://t/1c0000020106", + "wqhub://t/1c00000201f9", + "wqhub://t/1c0000020236", + "wqhub://t/1c0000021a09", + "wqhub://t/1c0000021a24", + "wqhub://t/1c0000021dae", + "wqhub://t/1c0000022070", + "wqhub://t/1c00000220c5", + "wqhub://t/1c0000022217", + "wqhub://t/1c0000023056", + "wqhub://t/1c00000231e9", + "wqhub://t/1c00000239d1", + "wqhub://t/1c0000025d0e", + "wqhub://t/1c0100008529", + "wqhub://t/1c010001d3d2", + "wqhub://t/1c010001dc9f", + "wqhub://t/1c0100020429", + "wqhub://t/1c01000217f6", + "wqhub://t/1c0100021a0f", + "wqhub://t/1c0100023c1a", + "wqhub://t/1c03000016ca", + "wqhub://t/1c03000178c5", + "wqhub://t/1c030001fcba", + "wqhub://t/1c040000a19d", + "wqhub://t/1c0500008c51", + "wqhub://t/1c0500008c8f", + "wqhub://t/1c070000570f", + "wqhub://t/1c0700005cae", + "wqhub://t/1c0700005d87", + "wqhub://t/1c070000611f", + "wqhub://t/1c0700006254", + "wqhub://t/1c0700006263", + "wqhub://t/1c0700006446", + "wqhub://t/1c0700006660", + "wqhub://t/1c0700007379", + "wqhub://t/1c070000bd53", + "wqhub://t/1c070000d0ad", + "wqhub://t/1c070001c052", + "wqhub://t/1c070001c05d", + "wqhub://t/1c070001c06c", + "wqhub://t/1c0700026299", + "wqhub://t/1c07000275a2", + "wqhub://t/1c08000273be", + "wqhub://t/1c09000037c4", + "wqhub://t/1c09000039bc", + "wqhub://t/1c09000042a1", + "wqhub://t/1c0900005b2a", + "wqhub://t/1c090000618a", + "wqhub://t/1c09000073fe", + "wqhub://t/1c0900009a12", + "wqhub://t/1c0900009b8f", + "wqhub://t/1c090000a756", + "wqhub://t/1c090000c1ea", + "wqhub://t/1c090000ef42", + "wqhub://t/1c0900010955", + "wqhub://t/1c0900011171", + "wqhub://t/1c0900011369", + "wqhub://t/1c09000139c8", + "wqhub://t/1c090001bc6a", + "wqhub://t/1c090001cbfd", + "wqhub://t/1c090001d059", + "wqhub://t/1c090001d563", + "wqhub://t/1c090001d8cc", + "wqhub://t/1c090001f02a", + "wqhub://t/1c090001f9b6", + "wqhub://t/1c090001fe42", + "wqhub://t/1c09000214a3", + "wqhub://t/1c0900021839", + "wqhub://t/1c0900022128", + "wqhub://t/1c0900022153", + "wqhub://t/1c090002239a", + "wqhub://t/1c0900023d2e", + "wqhub://t/1c09000250c3", + "wqhub://t/1c090002528e", + "wqhub://t/1d0000001811", + "wqhub://t/1d000000409f", + "wqhub://t/1d0000004158", + "wqhub://t/1d0000004a5c", + "wqhub://t/1d0000004a68", + "wqhub://t/1d0000005825", + "wqhub://t/1d0000005894", + "wqhub://t/1d0000005ed2", + "wqhub://t/1d0000005ed4", + "wqhub://t/1d0000005eef", + "wqhub://t/1d0000006167", + "wqhub://t/1d0000006184", + "wqhub://t/1d0000006193", + "wqhub://t/1d00000063fe", + "wqhub://t/1d0000006423", + "wqhub://t/1d000000642d", + "wqhub://t/1d0000006448", + "wqhub://t/1d0000006f00", + "wqhub://t/1d00000074f5", + "wqhub://t/1d0000007bda", + "wqhub://t/1d000000865f", + "wqhub://t/1d0000008796", + "wqhub://t/1d0000008d71", + "wqhub://t/1d0000008f07", + "wqhub://t/1d00000095fa", + "wqhub://t/1d000000ad0e", + "wqhub://t/1d000000b5ad", + "wqhub://t/1d000000b7d4", + "wqhub://t/1d000000bd02", + "wqhub://t/1d000000c2a6", + "wqhub://t/1d000000c339", + "wqhub://t/1d000000d2c3", + "wqhub://t/1d000000ea04", + "wqhub://t/1d000000f6a7", + "wqhub://t/1d0000010eb2", + "wqhub://t/1d0000011350", + "wqhub://t/1d000001199f", + "wqhub://t/1d0000013237", + "wqhub://t/1d0000014205", + "wqhub://t/1d000001429d", + "wqhub://t/1d0000014b0f", + "wqhub://t/1d0000014f3b", + "wqhub://t/1d000001533a", + "wqhub://t/1d0000015721", + "wqhub://t/1d0000015d95", + "wqhub://t/1d00000160ce", + "wqhub://t/1d0000016c34", + "wqhub://t/1d0000017042", + "wqhub://t/1d0000018037", + "wqhub://t/1d0000018780", + "wqhub://t/1d0000019427", + "wqhub://t/1d0000019cf0", + "wqhub://t/1d000001a52e", + "wqhub://t/1d000001a541", + "wqhub://t/1d000001a8fa", + "wqhub://t/1d000001bccd", + "wqhub://t/1d000001c75a", + "wqhub://t/1d000001d4f3", + "wqhub://t/1d000001e52a", + "wqhub://t/1d000001f4ea", + "wqhub://t/1d000001f775", + "wqhub://t/1d000001f9a1", + "wqhub://t/1d000001faa2", + "wqhub://t/1d000001fb46", + "wqhub://t/1d000001fe74", + "wqhub://t/1d000001feb6", + "wqhub://t/1d000002000e", + "wqhub://t/1d00000200fb", + "wqhub://t/1d000002068a", + "wqhub://t/1d00000217a9", + "wqhub://t/1d0000021a00", + "wqhub://t/1d0000021a1a", + "wqhub://t/1d0000021c28", + "wqhub://t/1d0000021e0f", + "wqhub://t/1d0000021e87", + "wqhub://t/1d00000220c4", + "wqhub://t/1d00000223d0", + "wqhub://t/1d0000022421", + "wqhub://t/1d00000226f7", + "wqhub://t/1d000002323d", + "wqhub://t/1d0000023a2a", + "wqhub://t/1d0000023acb", + "wqhub://t/1d0000024d06", + "wqhub://t/1d000002591f", + "wqhub://t/1d0000026084", + "wqhub://t/1d0100006445", + "wqhub://t/1d0100007806", + "wqhub://t/1d0100007e5d", + "wqhub://t/1d010000878a", + "wqhub://t/1d0100008c9d", + "wqhub://t/1d0100010dd5", + "wqhub://t/1d0100016abf", + "wqhub://t/1d010001adc2", + "wqhub://t/1d010001c104", + "wqhub://t/1d0100020b5a", + "wqhub://t/1d0100020ce4", + "wqhub://t/1d0100023e2c", + "wqhub://t/1d0300005755", + "wqhub://t/1d0300008d82", + "wqhub://t/1d03000104fc", + "wqhub://t/1d0300017b6b", + "wqhub://t/1d030001bec7", + "wqhub://t/1d0300020468", + "wqhub://t/1d03000252a5", + "wqhub://t/1d040000b332", + "wqhub://t/1d0700001214", + "wqhub://t/1d0700005cb5", + "wqhub://t/1d0700005d78", + "wqhub://t/1d0700005d89", + "wqhub://t/1d0700005db0", + "wqhub://t/1d0700006439", + "wqhub://t/1d0700006447", + "wqhub://t/1d0700009513", + "wqhub://t/1d070000ca0f", + "wqhub://t/1d070001e774", + "wqhub://t/1d070001e78d", + "wqhub://t/1d070001fc53", + "wqhub://t/1d0700022f59", + "wqhub://t/1d0900006436", + "wqhub://t/1d0900006eda", + "wqhub://t/1d09000086b8", + "wqhub://t/1d090000a7e9", + "wqhub://t/1d090000ed5c", + "wqhub://t/1d09000101bd", + "wqhub://t/1d090001032a", + "wqhub://t/1d090001032b", + "wqhub://t/1d0900010894", + "wqhub://t/1d090001113b", + "wqhub://t/1d09000112c3", + "wqhub://t/1d0900016070", + "wqhub://t/1d09000193b5", + "wqhub://t/1d090001c1a7", + "wqhub://t/1d090001d5c2", + "wqhub://t/1d090001ef38", + "wqhub://t/1d0900020f53", + "wqhub://t/1d090002188f", + "wqhub://t/1d09000220a7", + "wqhub://t/1d0900022fa7", + "wqhub://t/1d09000251ee", + "wqhub://t/1d09000252f9", + "wqhub://t/1d0900025301", + "wqhub://t/1d09000253c5", + "wqhub://t/1d0900025520", + "wqhub://t/1d09000273eb", + "wqhub://t/1e0000000c18", + "wqhub://t/1e00000015b3", + "wqhub://t/1e00000043ee", + "wqhub://t/1e0000005bdb", + "wqhub://t/1e0000005e73", + "wqhub://t/1e0000005efe", + "wqhub://t/1e0000006151", + "wqhub://t/1e0000006157", + "wqhub://t/1e0000006179", + "wqhub://t/1e0000006188", + "wqhub://t/1e0000006402", + "wqhub://t/1e0000006408", + "wqhub://t/1e000000643d", + "wqhub://t/1e0000006444", + "wqhub://t/1e00000064c5", + "wqhub://t/1e0000006ca9", + "wqhub://t/1e000000700b", + "wqhub://t/1e0000008044", + "wqhub://t/1e0000008602", + "wqhub://t/1e0000008c19", + "wqhub://t/1e0000008eb3", + "wqhub://t/1e000000921b", + "wqhub://t/1e0000009a4e", + "wqhub://t/1e000000d293", + "wqhub://t/1e000000f700", + "wqhub://t/1e000000f746", + "wqhub://t/1e0000010abb", + "wqhub://t/1e0000010cee", + "wqhub://t/1e0000012990", + "wqhub://t/1e0000015ec5", + "wqhub://t/1e0000016778", + "wqhub://t/1e00000174b6", + "wqhub://t/1e0000017b5a", + "wqhub://t/1e0000017c4c", + "wqhub://t/1e000001936f", + "wqhub://t/1e000001a528", + "wqhub://t/1e000001a9ca", + "wqhub://t/1e000001ad92", + "wqhub://t/1e000001bdd4", + "wqhub://t/1e000001cd81", + "wqhub://t/1e000001dbb3", + "wqhub://t/1e000001e3c2", + "wqhub://t/1e000001f00e", + "wqhub://t/1e000001f84d", + "wqhub://t/1e000001ff10", + "wqhub://t/1e000001ff9b", + "wqhub://t/1e000002024f", + "wqhub://t/1e00000222f3", + "wqhub://t/1e000002275e", + "wqhub://t/1e000002313b", + "wqhub://t/1e0000023ddc", + "wqhub://t/1e0000023e24", + "wqhub://t/1e0000024d60", + "wqhub://t/1e0000025678", + "wqhub://t/1e0000025aae", + "wqhub://t/1e0100006608", + "wqhub://t/1e01000087d0", + "wqhub://t/1e0100012892", + "wqhub://t/1e0100019f82", + "wqhub://t/1e010001afe5", + "wqhub://t/1e010001c28c", + "wqhub://t/1e010001c982", + "wqhub://t/1e010001d66f", + "wqhub://t/1e0100025512", + "wqhub://t/1e0300018136", + "wqhub://t/1e030001fd05", + "wqhub://t/1e0500008c17", + "wqhub://t/1e070000101f", + "wqhub://t/1e0700001021", + "wqhub://t/1e0700001043", + "wqhub://t/1e0700001750", + "wqhub://t/1e0700005c93", + "wqhub://t/1e0700005cad", + "wqhub://t/1e0700005d72", + "wqhub://t/1e0700005d81", + "wqhub://t/1e0700005d8e", + "wqhub://t/1e0700005d91", + "wqhub://t/1e0700005d92", + "wqhub://t/1e0700006275", + "wqhub://t/1e0700006431", + "wqhub://t/1e070000925d", + "wqhub://t/1e070000969b", + "wqhub://t/1e070000c9be", + "wqhub://t/1e070000e9e4", + "wqhub://t/1e070001c0f8", + "wqhub://t/1e070001fb73", + "wqhub://t/1e070001fcdd", + "wqhub://t/1e07000219f1", + "wqhub://t/1e0700023b85", + "wqhub://t/1e0700025b8a", + "wqhub://t/1e0900000b50", + "wqhub://t/1e0900002cdb", + "wqhub://t/1e0900003fd5", + "wqhub://t/1e09000041f8", + "wqhub://t/1e0900005b71", + "wqhub://t/1e0900005d8d", + "wqhub://t/1e0900005e5a", + "wqhub://t/1e0900005e61", + "wqhub://t/1e0900005e62", + "wqhub://t/1e0900005e63", + "wqhub://t/1e0900005e64", + "wqhub://t/1e0900006147", + "wqhub://t/1e0900006172", + "wqhub://t/1e090000617f", + "wqhub://t/1e090000618c", + "wqhub://t/1e0900006440", + "wqhub://t/1e090000645b", + "wqhub://t/1e0900006462", + "wqhub://t/1e0900006463", + "wqhub://t/1e0900006492", + "wqhub://t/1e09000064ac", + "wqhub://t/1e090000855b", + "wqhub://t/1e09000088d2", + "wqhub://t/1e0900008c55", + "wqhub://t/1e0900009556", + "wqhub://t/1e0900009753", + "wqhub://t/1e0900009850", + "wqhub://t/1e0900010221", + "wqhub://t/1e0900011392", + "wqhub://t/1e0900012979", + "wqhub://t/1e0900012ca1", + "wqhub://t/1e0900015179", + "wqhub://t/1e090001584f", + "wqhub://t/1e0900015851", + "wqhub://t/1e0900015ca2", + "wqhub://t/1e090001a6db", + "wqhub://t/1e090001ad9c", + "wqhub://t/1e090001af8f", + "wqhub://t/1e090001ba0c", + "wqhub://t/1e090001c16c", + "wqhub://t/1e090001d819", + "wqhub://t/1e090001d9c4", + "wqhub://t/1e090001e9b6", + "wqhub://t/1e090001f9ae", + "wqhub://t/1e0900021100", + "wqhub://t/1e09000219f3", + "wqhub://t/1e090002267f", + "wqhub://t/1e0900022a3a", + "wqhub://t/1e09000231dd", + "wqhub://t/1e0900024f15", + "wqhub://t/1e0900025162", + "wqhub://t/1e09000251e3", + "wqhub://t/1e09000253d4", + "wqhub://t/1f00000012ae", + "wqhub://t/1f000000368d", + "wqhub://t/1f000000435b", + "wqhub://t/1f00000045e3", + "wqhub://t/1f0000005b37", + "wqhub://t/1f0000006153", + "wqhub://t/1f0000006164", + "wqhub://t/1f0000006175", + "wqhub://t/1f000000644c", + "wqhub://t/1f0000006c1f", + "wqhub://t/1f0000006dc7", + "wqhub://t/1f0000006fd5", + "wqhub://t/1f0000007362", + "wqhub://t/1f00000075d1", + "wqhub://t/1f000000770a", + "wqhub://t/1f000000771a", + "wqhub://t/1f000000774b", + "wqhub://t/1f000000775e", + "wqhub://t/1f0000007adb", + "wqhub://t/1f0000008049", + "wqhub://t/1f0000008f17", + "wqhub://t/1f0000008f59", + "wqhub://t/1f00000092b4", + "wqhub://t/1f000000986d", + "wqhub://t/1f000000992f", + "wqhub://t/1f000000a62e", + "wqhub://t/1f000000d8e6", + "wqhub://t/1f000000ddac", + "wqhub://t/1f000000f150", + "wqhub://t/1f000000f6da", + "wqhub://t/1f0000010549", + "wqhub://t/1f00000106db", + "wqhub://t/1f0000010d09", + "wqhub://t/1f0000011812", + "wqhub://t/1f00000128f6", + "wqhub://t/1f0000012dfc", + "wqhub://t/1f00000153e7", + "wqhub://t/1f00000167e7", + "wqhub://t/1f0000016cc1", + "wqhub://t/1f000001705d", + "wqhub://t/1f0000017c52", + "wqhub://t/1f0000017c5e", + "wqhub://t/1f0000019a5d", + "wqhub://t/1f000001a181", + "wqhub://t/1f000001ad7d", + "wqhub://t/1f000001adb5", + "wqhub://t/1f000001af7f", + "wqhub://t/1f000001b404", + "wqhub://t/1f000001be39", + "wqhub://t/1f000001be5d", + "wqhub://t/1f000001c6a6", + "wqhub://t/1f000001c89c", + "wqhub://t/1f000001d506", + "wqhub://t/1f000001ddd3", + "wqhub://t/1f000001e18a", + "wqhub://t/1f000001e18e", + "wqhub://t/1f000001ead5", + "wqhub://t/1f000001eb8b", + "wqhub://t/1f000001fc7f", + "wqhub://t/1f00000201cc", + "wqhub://t/1f00000213f5", + "wqhub://t/1f000002191c", + "wqhub://t/1f0000021a32", + "wqhub://t/1f000002205e", + "wqhub://t/1f00000225c2", + "wqhub://t/1f00000230ed", + "wqhub://t/1f0000025a6b", + "wqhub://t/1f0000026911", + "wqhub://t/1f0100007824", + "wqhub://t/1f0100008a20", + "wqhub://t/1f0100010def", + "wqhub://t/1f0100012ef0", + "wqhub://t/1f010001580b", + "wqhub://t/1f01000181d4", + "wqhub://t/1f010001941b", + "wqhub://t/1f010001adbb", + "wqhub://t/1f010001c8c9", + "wqhub://t/1f010001d5a9", + "wqhub://t/1f010001d9b8", + "wqhub://t/1f010001dfe5", + "wqhub://t/1f010001fa81", + "wqhub://t/1f03000058ab", + "wqhub://t/1f03000088e6", + "wqhub://t/1f0300009175", + "wqhub://t/1f030002740d", + "wqhub://t/1f0500008c7f", + "wqhub://t/1f0700007565", + "wqhub://t/1f070001c064", + "wqhub://t/1f070001cffd", + "wqhub://t/1f070001daaf", + "wqhub://t/1f070001db54", + "wqhub://t/1f070001e268", + "wqhub://t/1f070001eb1c", + "wqhub://t/1f070001fcd5", + "wqhub://t/1f09000014fa", + "wqhub://t/1f09000026b4", + "wqhub://t/1f09000026c2", + "wqhub://t/1f0900002b54", + "wqhub://t/1f0900003a0e", + "wqhub://t/1f09000049aa", + "wqhub://t/1f090000500b", + "wqhub://t/1f0900005431", + "wqhub://t/1f09000054bf", + "wqhub://t/1f0900005b6a", + "wqhub://t/1f09000063d0", + "wqhub://t/1f09000063fd", + "wqhub://t/1f0900006443", + "wqhub://t/1f0900006d7f", + "wqhub://t/1f0900006de6", + "wqhub://t/1f0900007fa8", + "wqhub://t/1f090000820e", + "wqhub://t/1f09000083e9", + "wqhub://t/1f090000943e", + "wqhub://t/1f090000952d", + "wqhub://t/1f09000095fe", + "wqhub://t/1f090000960a", + "wqhub://t/1f0900009b72", + "wqhub://t/1f0900009c2c", + "wqhub://t/1f0900009d02", + "wqhub://t/1f0900009e18", + "wqhub://t/1f090000ba85", + "wqhub://t/1f090000c0d9", + "wqhub://t/1f090000d0c2", + "wqhub://t/1f090000f3a5", + "wqhub://t/1f0900012a60", + "wqhub://t/1f09000154d2", + "wqhub://t/1f090001569f", + "wqhub://t/1f0900015837", + "wqhub://t/1f0900015839", + "wqhub://t/1f090001583a", + "wqhub://t/1f0900015854", + "wqhub://t/1f0900015860", + "wqhub://t/1f09000158f4", + "wqhub://t/1f09000158f9", + "wqhub://t/1f09000158ff", + "wqhub://t/1f0900017a57", + "wqhub://t/1f0900017dcb", + "wqhub://t/1f090001819f", + "wqhub://t/1f09000197f9", + "wqhub://t/1f0900019fd7", + "wqhub://t/1f090001abfb", + "wqhub://t/1f090001b358", + "wqhub://t/1f090001c095", + "wqhub://t/1f090001c220", + "wqhub://t/1f090001c2a9", + "wqhub://t/1f090001cd8d", + "wqhub://t/1f090001d629", + "wqhub://t/1f090001da51", + "wqhub://t/1f090001ea41", + "wqhub://t/1f090001ef9e", + "wqhub://t/1f090001fe44", + "wqhub://t/1f0900020916", + "wqhub://t/1f090002135f", + "wqhub://t/1f09000221ac", + "wqhub://t/1f0900022283", + "wqhub://t/1f0900023137", + "wqhub://t/1f090002509a", + "wqhub://t/1f09000250eb", + "wqhub://t/1f090002519c", + "wqhub://t/1f09000253e3", + "wqhub://t/2000000007a3", + "wqhub://t/2000000009c6", + "wqhub://t/20000000483a", + "wqhub://t/200000004ac4", + "wqhub://t/200000004f9e", + "wqhub://t/200000006148", + "wqhub://t/20000000614b", + "wqhub://t/200000006156", + "wqhub://t/200000006174", + "wqhub://t/20000000617a", + "wqhub://t/20000000617b", + "wqhub://t/200000006192", + "wqhub://t/200000006199", + "wqhub://t/20000000619b", + "wqhub://t/20000000619c", + "wqhub://t/2000000075f9", + "wqhub://t/2000000076f6", + "wqhub://t/200000007fb0", + "wqhub://t/200000008c8a", + "wqhub://t/200000008d93", + "wqhub://t/20000000957f", + "wqhub://t/200000009606", + "wqhub://t/200000009a4a", + "wqhub://t/200000009f76", + "wqhub://t/20000000a64b", + "wqhub://t/20000000a8eb", + "wqhub://t/20000000e8ae", + "wqhub://t/20000000f131", + "wqhub://t/20000000fd69", + "wqhub://t/200000012cc6", + "wqhub://t/2000000134b0", + "wqhub://t/200000015036", + "wqhub://t/2000000152e2", + "wqhub://t/20000001542a", + "wqhub://t/2000000156e7", + "wqhub://t/200000016355", + "wqhub://t/200000016a4d", + "wqhub://t/20000001704d", + "wqhub://t/200000017871", + "wqhub://t/200000017aab", + "wqhub://t/200000017c56", + "wqhub://t/200000017c57", + "wqhub://t/2000000187dd", + "wqhub://t/200000018e48", + "wqhub://t/2000000190f7", + "wqhub://t/200000019312", + "wqhub://t/20000001980f", + "wqhub://t/200000019924", + "wqhub://t/200000019a9a", + "wqhub://t/200000019baa", + "wqhub://t/20000001a191", + "wqhub://t/20000001a220", + "wqhub://t/20000001a31b", + "wqhub://t/20000001a871", + "wqhub://t/20000001aa36", + "wqhub://t/20000001ab3b", + "wqhub://t/20000001afb3", + "wqhub://t/20000001b505", + "wqhub://t/20000001baff", + "wqhub://t/20000001bda8", + "wqhub://t/20000001db79", + "wqhub://t/20000001e2c8", + "wqhub://t/20000001e42a", + "wqhub://t/20000001f686", + "wqhub://t/2000000200be", + "wqhub://t/20000002020f", + "wqhub://t/200000020886", + "wqhub://t/200000020c92", + "wqhub://t/200000020f43", + "wqhub://t/20000002134d", + "wqhub://t/2000000216a5", + "wqhub://t/200000022fe2", + "wqhub://t/20000002318a", + "wqhub://t/20010000a026", + "wqhub://t/200100019445", + "wqhub://t/20010001d676", + "wqhub://t/2001000216bb", + "wqhub://t/200300007faf", + "wqhub://t/200500008bdc", + "wqhub://t/20050001b10c", + "wqhub://t/2006000190c0", + "wqhub://t/200700005462", + "wqhub://t/200700005ca3", + "wqhub://t/200700005d83", + "wqhub://t/200700005d96", + "wqhub://t/2007000076ad", + "wqhub://t/200700019fb0", + "wqhub://t/200700019fb8", + "wqhub://t/2007000200d8", + "wqhub://t/2007000200e1", + "wqhub://t/200900000c08", + "wqhub://t/200900000c9a", + "wqhub://t/200900000d85", + "wqhub://t/200900001863", + "wqhub://t/200900002677", + "wqhub://t/200900002c39", + "wqhub://t/200900003702", + "wqhub://t/20090000372e", + "wqhub://t/20090000420b", + "wqhub://t/2009000045bf", + "wqhub://t/2009000051ad", + "wqhub://t/200900005228", + "wqhub://t/200900005323", + "wqhub://t/20090000547c", + "wqhub://t/20090000548f", + "wqhub://t/200900005510", + "wqhub://t/20090000551f", + "wqhub://t/200900005526", + "wqhub://t/2009000055b9", + "wqhub://t/200900005b66", + "wqhub://t/200900005d02", + "wqhub://t/200900005e56", + "wqhub://t/200900006170", + "wqhub://t/200900006187", + "wqhub://t/20090000643a", + "wqhub://t/200900006a22", + "wqhub://t/200900006cfb", + "wqhub://t/2009000071d4", + "wqhub://t/200900007bc1", + "wqhub://t/200900008211", + "wqhub://t/200900008377", + "wqhub://t/2009000086ac", + "wqhub://t/20090000894e", + "wqhub://t/200900008d01", + "wqhub://t/20090000930c", + "wqhub://t/200900009457", + "wqhub://t/200900009471", + "wqhub://t/2009000094c0", + "wqhub://t/200900009503", + "wqhub://t/2009000095ef", + "wqhub://t/20090000961e", + "wqhub://t/200900009a2b", + "wqhub://t/200900009ff3", + "wqhub://t/20090000c6ae", + "wqhub://t/20090000f555", + "wqhub://t/200900010d07", + "wqhub://t/2009000113c9", + "wqhub://t/2009000137fc", + "wqhub://t/200900015838", + "wqhub://t/200900015844", + "wqhub://t/200900016a59", + "wqhub://t/20090001789d", + "wqhub://t/200900019c40", + "wqhub://t/20090001abc3", + "wqhub://t/20090001add9", + "wqhub://t/20090001bd3e", + "wqhub://t/20090001bf73", + "wqhub://t/20090001c1df", + "wqhub://t/20090001c1ec", + "wqhub://t/20090001c1ee", + "wqhub://t/20090001d344", + "wqhub://t/20090001d376", + "wqhub://t/20090001ddee", + "wqhub://t/20090001e269", + "wqhub://t/20090001ff47", + "wqhub://t/2009000201b7", + "wqhub://t/2009000204d5", + "wqhub://t/200900020840", + "wqhub://t/200900020ac7", + "wqhub://t/20090002147a", + "wqhub://t/2009000221e1", + "wqhub://t/200900024f4a", + "wqhub://t/200900025152", + "wqhub://t/200900025173", + "wqhub://t/200900025e2e", + "wqhub://t/200900026108", + "wqhub://t/20090002641b", + "wqhub://t/2009000275ee", + "wqhub://t/200900027617", + "wqhub://t/210000001122", + "wqhub://t/2100000013ec", + "wqhub://t/2100000014b6", + "wqhub://t/210000001f31", + "wqhub://t/21000000205c", + "wqhub://t/210000003bf5", + "wqhub://t/210000003c9b", + "wqhub://t/210000005706", + "wqhub://t/210000005912", + "wqhub://t/210000005a01", + "wqhub://t/210000005baf", + "wqhub://t/210000005f24", + "wqhub://t/210000006150", + "wqhub://t/21000000615a", + "wqhub://t/21000000619a", + "wqhub://t/210000006cce", + "wqhub://t/21000000761c", + "wqhub://t/210000007a95", + "wqhub://t/210000007aca", + "wqhub://t/210000007c7d", + "wqhub://t/210000007f37", + "wqhub://t/210000007f61", + "wqhub://t/210000008043", + "wqhub://t/2100000081e7", + "wqhub://t/2100000082d6", + "wqhub://t/2100000083a9", + "wqhub://t/2100000083e2", + "wqhub://t/21000000882e", + "wqhub://t/210000008a3d", + "wqhub://t/210000008f03", + "wqhub://t/21000000974c", + "wqhub://t/210000009b6b", + "wqhub://t/210000009f47", + "wqhub://t/21000000a041", + "wqhub://t/21000000b55f", + "wqhub://t/21000000c324", + "wqhub://t/21000000d4fc", + "wqhub://t/21000000e3c3", + "wqhub://t/21000000ea86", + "wqhub://t/21000000eab3", + "wqhub://t/21000000eb0a", + "wqhub://t/21000000eb26", + "wqhub://t/2100000102ea", + "wqhub://t/210000010878", + "wqhub://t/210000011a9d", + "wqhub://t/210000012c3e", + "wqhub://t/210000013663", + "wqhub://t/210000013f13", + "wqhub://t/21000001408f", + "wqhub://t/2100000149fb", + "wqhub://t/2100000152f7", + "wqhub://t/21000001554e", + "wqhub://t/210000015f24", + "wqhub://t/2100000163ef", + "wqhub://t/21000001650d", + "wqhub://t/210000016a3e", + "wqhub://t/210000017253", + "wqhub://t/210000017666", + "wqhub://t/210000017789", + "wqhub://t/2100000178c7", + "wqhub://t/210000017cfb", + "wqhub://t/210000019701", + "wqhub://t/210000019840", + "wqhub://t/2100000198f5", + "wqhub://t/210000019907", + "wqhub://t/210000019976", + "wqhub://t/2100000199cd", + "wqhub://t/21000001a522", + "wqhub://t/21000001ad3f", + "wqhub://t/21000001ad7f", + "wqhub://t/21000001cd73", + "wqhub://t/21000001dc5d", + "wqhub://t/21000001dd58", + "wqhub://t/21000001e260", + "wqhub://t/21000001e67a", + "wqhub://t/21000001e6e1", + "wqhub://t/21000001fc9b", + "wqhub://t/21000001ff4c", + "wqhub://t/210000020023", + "wqhub://t/2100000202f3", + "wqhub://t/210000020a7a", + "wqhub://t/210000020b28", + "wqhub://t/210000020be0", + "wqhub://t/21000002167c", + "wqhub://t/210000021849", + "wqhub://t/210000021c27", + "wqhub://t/210000022a24", + "wqhub://t/2100000230a9", + "wqhub://t/2100000230df", + "wqhub://t/210000023239", + "wqhub://t/2100000234da", + "wqhub://t/210000023d30", + "wqhub://t/210000023daf", + "wqhub://t/210000025100", + "wqhub://t/21000002568d", + "wqhub://t/21000002589b", + "wqhub://t/2100000264be", + "wqhub://t/2100000268e7", + "wqhub://t/210000026f8c", + "wqhub://t/210100003a07", + "wqhub://t/2101000058d0", + "wqhub://t/210100006451", + "wqhub://t/2101000078cb", + "wqhub://t/210100008180", + "wqhub://t/210100008a92", + "wqhub://t/21010000b266", + "wqhub://t/21010001505e", + "wqhub://t/21010001637b", + "wqhub://t/21010001d8c3", + "wqhub://t/21030000552b", + "wqhub://t/21030001f8e0", + "wqhub://t/2107000054ad", + "wqhub://t/2107000054ae", + "wqhub://t/210700005ca2", + "wqhub://t/210700005d8f", + "wqhub://t/2107000089ca", + "wqhub://t/21070000949f", + "wqhub://t/210700009536", + "wqhub://t/2107000219f2", + "wqhub://t/210900000c35", + "wqhub://t/210900002acd", + "wqhub://t/210900003483", + "wqhub://t/210900003490", + "wqhub://t/210900003f56", + "wqhub://t/21090000545e", + "wqhub://t/210900005f3a", + "wqhub://t/210900005fc9", + "wqhub://t/210900006198", + "wqhub://t/210900006bd4", + "wqhub://t/210900006eef", + "wqhub://t/210900006f51", + "wqhub://t/2109000072ea", + "wqhub://t/21090000795f", + "wqhub://t/210900007aa5", + "wqhub://t/210900007e91", + "wqhub://t/210900008222", + "wqhub://t/21090000851f", + "wqhub://t/210900008b7f", + "wqhub://t/210900008f55", + "wqhub://t/2109000093e6", + "wqhub://t/210900009da2", + "wqhub://t/21090000a0b2", + "wqhub://t/21090000bdfd", + "wqhub://t/21090000cdd9", + "wqhub://t/21090000d110", + "wqhub://t/21090000d115", + "wqhub://t/21090000ead1", + "wqhub://t/21090000f6b6", + "wqhub://t/21090000fdd9", + "wqhub://t/210900010fba", + "wqhub://t/210900010fde", + "wqhub://t/21090001290e", + "wqhub://t/210900012b77", + "wqhub://t/210900012c05", + "wqhub://t/210900012c0b", + "wqhub://t/210900015631", + "wqhub://t/210900015812", + "wqhub://t/21090001590c", + "wqhub://t/210900016eef", + "wqhub://t/2109000172ad", + "wqhub://t/210900018253", + "wqhub://t/210900019014", + "wqhub://t/21090001960f", + "wqhub://t/210900019700", + "wqhub://t/210900019747", + "wqhub://t/2109000197c2", + "wqhub://t/210900019895", + "wqhub://t/210900019dda", + "wqhub://t/21090001a3ba", + "wqhub://t/21090001aac1", + "wqhub://t/21090001acb7", + "wqhub://t/21090001acca", + "wqhub://t/21090001aec1", + "wqhub://t/21090001c166", + "wqhub://t/21090001c168", + "wqhub://t/21090001c34d", + "wqhub://t/21090001c44c", + "wqhub://t/21090001c6ba", + "wqhub://t/21090001d205", + "wqhub://t/21090001d62c", + "wqhub://t/21090001d8a0", + "wqhub://t/21090001e195", + "wqhub://t/21090001e1c0", + "wqhub://t/21090001e1cf", + "wqhub://t/21090001e231", + "wqhub://t/21090001e73e", + "wqhub://t/21090001ea94", + "wqhub://t/21090001eaaf", + "wqhub://t/21090001eb0e", + "wqhub://t/21090001fece", + "wqhub://t/21090001ff1b", + "wqhub://t/21090001ffab", + "wqhub://t/2109000200a6", + "wqhub://t/210900020f21", + "wqhub://t/2109000214a2", + "wqhub://t/21090002155c", + "wqhub://t/21090002186a", + "wqhub://t/210900021952", + "wqhub://t/210900021b28", + "wqhub://t/210900021b74", + "wqhub://t/210900022037", + "wqhub://t/2109000220c7", + "wqhub://t/2109000220f8", + "wqhub://t/210900022191", + "wqhub://t/2109000252d5", + "wqhub://t/2109000252e1", + "wqhub://t/2109000254c9", + "wqhub://t/2109000256f3", + "wqhub://t/210900027127", + "wqhub://t/220000000150", + "wqhub://t/220000000553", + "wqhub://t/220000000773", + "wqhub://t/220000000b66", + "wqhub://t/220000000c3c", + "wqhub://t/220000000c57", + "wqhub://t/220000000efa", + "wqhub://t/2200000011d6", + "wqhub://t/2200000013df", + "wqhub://t/220000001762", + "wqhub://t/220000001819", + "wqhub://t/220000002348", + "wqhub://t/220000002394", + "wqhub://t/220000002e0e", + "wqhub://t/220000002e69", + "wqhub://t/2200000030fd", + "wqhub://t/2200000034fd", + "wqhub://t/220000003899", + "wqhub://t/2200000038c4", + "wqhub://t/220000003bd8", + "wqhub://t/220000003c1b", + "wqhub://t/220000003c2b", + "wqhub://t/220000003c2d", + "wqhub://t/220000003c97", + "wqhub://t/220000003cb4", + "wqhub://t/220000003cbd", + "wqhub://t/220000003cc7", + "wqhub://t/220000003ccb", + "wqhub://t/220000003cd1", + "wqhub://t/220000003cd4", + "wqhub://t/220000003ce1", + "wqhub://t/220000003cf8", + "wqhub://t/220000003d32", + "wqhub://t/220000003df6", + "wqhub://t/220000003dfd", + "wqhub://t/220000003e18", + "wqhub://t/220000003e3a", + "wqhub://t/220000004631", + "wqhub://t/220000004791", + "wqhub://t/2200000053ea", + "wqhub://t/2200000058a8", + "wqhub://t/220000005931", + "wqhub://t/2200000062ba", + "wqhub://t/220000006794", + "wqhub://t/220000006de5", + "wqhub://t/220000006ec1", + "wqhub://t/2200000076ac", + "wqhub://t/220000007ab9", + "wqhub://t/220000007b5c", + "wqhub://t/220000007da6", + "wqhub://t/22000000817d", + "wqhub://t/2200000083d0", + "wqhub://t/220000008b43", + "wqhub://t/220000008c39", + "wqhub://t/220000008c68", + "wqhub://t/220000008c69", + "wqhub://t/220000008c76", + "wqhub://t/22000000958d", + "wqhub://t/220000009a50", + "wqhub://t/220000009aaf", + "wqhub://t/220000009ddf", + "wqhub://t/220000009f8a", + "wqhub://t/22000000ab84", + "wqhub://t/22000000ac15", + "wqhub://t/22000000b563", + "wqhub://t/22000000b5cd", + "wqhub://t/22000000bcfb", + "wqhub://t/22000000d390", + "wqhub://t/22000000d868", + "wqhub://t/22000000dddf", + "wqhub://t/22000000e7e2", + "wqhub://t/22000000e9c5", + "wqhub://t/22000000eb31", + "wqhub://t/22000000f470", + "wqhub://t/220000010351", + "wqhub://t/220000010724", + "wqhub://t/220000010787", + "wqhub://t/220000010a4d", + "wqhub://t/220000010cb0", + "wqhub://t/220000010fdd", + "wqhub://t/2200000110ac", + "wqhub://t/220000011882", + "wqhub://t/2200000118bb", + "wqhub://t/2200000128ef", + "wqhub://t/220000012b31", + "wqhub://t/220000012ca9", + "wqhub://t/2200000134f2", + "wqhub://t/220000013f76", + "wqhub://t/220000014079", + "wqhub://t/220000014a63", + "wqhub://t/220000014fe9", + "wqhub://t/220000015209", + "wqhub://t/2200000152bf", + "wqhub://t/220000015546", + "wqhub://t/220000015559", + "wqhub://t/22000001555a", + "wqhub://t/22000001559a", + "wqhub://t/2200000155f8", + "wqhub://t/220000015610", + "wqhub://t/220000015c2d", + "wqhub://t/2200000160de", + "wqhub://t/220000016130", + "wqhub://t/2200000161ba", + "wqhub://t/22000001670b", + "wqhub://t/2200000168fd", + "wqhub://t/220000016c81", + "wqhub://t/220000016e84", + "wqhub://t/220000017520", + "wqhub://t/220000017527", + "wqhub://t/2200000175da", + "wqhub://t/22000001771b", + "wqhub://t/22000001798b", + "wqhub://t/220000017ac8", + "wqhub://t/220000017b62", + "wqhub://t/220000017c36", + "wqhub://t/220000017c61", + "wqhub://t/220000017c68", + "wqhub://t/220000018a8a", + "wqhub://t/220000018d4b", + "wqhub://t/2200000191d4", + "wqhub://t/220000019234", + "wqhub://t/220000019491", + "wqhub://t/220000019613", + "wqhub://t/22000001973e", + "wqhub://t/220000019744", + "wqhub://t/2200000198f6", + "wqhub://t/220000019b75", + "wqhub://t/220000019bb1", + "wqhub://t/220000019edc", + "wqhub://t/22000001a552", + "wqhub://t/22000001a911", + "wqhub://t/22000001ab48", + "wqhub://t/22000001ac6f", + "wqhub://t/22000001b210", + "wqhub://t/22000001ba24", + "wqhub://t/22000001ba75", + "wqhub://t/22000001bc5c", + "wqhub://t/22000001c45e", + "wqhub://t/22000001c8cd", + "wqhub://t/22000001cb37", + "wqhub://t/22000001de0e", + "wqhub://t/22000001e246", + "wqhub://t/22000001e24c", + "wqhub://t/22000001e732", + "wqhub://t/22000001e751", + "wqhub://t/22000001eb06", + "wqhub://t/22000001eb84", + "wqhub://t/22000001eda4", + "wqhub://t/22000001edcc", + "wqhub://t/22000001ff5c", + "wqhub://t/2200000200b9", + "wqhub://t/2200000201d8", + "wqhub://t/220000020216", + "wqhub://t/22000002030b", + "wqhub://t/22000002034f", + "wqhub://t/220000020382", + "wqhub://t/22000002044b", + "wqhub://t/22000002047f", + "wqhub://t/22000002099e", + "wqhub://t/2200000209a4", + "wqhub://t/220000020a04", + "wqhub://t/220000020adb", + "wqhub://t/220000021386", + "wqhub://t/220000021631", + "wqhub://t/2200000218ea", + "wqhub://t/220000022729", + "wqhub://t/22000002304a", + "wqhub://t/220000023066", + "wqhub://t/220000023078", + "wqhub://t/220000023203", + "wqhub://t/220000023431", + "wqhub://t/220000024cfe", + "wqhub://t/220000025567", + "wqhub://t/2200000268be", + "wqhub://t/2200000269b9", + "wqhub://t/2200000270ce", + "wqhub://t/220000027439", + "wqhub://t/220000027461", + "wqhub://t/220100006450", + "wqhub://t/22010000782b", + "wqhub://t/220100009124", + "wqhub://t/2201000103a6", + "wqhub://t/220100011ce4", + "wqhub://t/220100014f74", + "wqhub://t/220100015072", + "wqhub://t/22010001560a", + "wqhub://t/22010001852a", + "wqhub://t/2201000191bf", + "wqhub://t/220100019c76", + "wqhub://t/2202000080ec", + "wqhub://t/220300015510", + "wqhub://t/220300015f18", + "wqhub://t/22030001c2e7", + "wqhub://t/220300020385", + "wqhub://t/220700005506", + "wqhub://t/220700005517", + "wqhub://t/220700006697", + "wqhub://t/2207000084b2", + "wqhub://t/22070001aeed", + "wqhub://t/22070001f865", + "wqhub://t/220700023e39", + "wqhub://t/22090000006f", + "wqhub://t/220900000c21", + "wqhub://t/220900001215", + "wqhub://t/2209000014b4", + "wqhub://t/22090000158a", + "wqhub://t/220900002319", + "wqhub://t/220900002700", + "wqhub://t/2209000030de", + "wqhub://t/220900003688", + "wqhub://t/2209000039f7", + "wqhub://t/220900003f03", + "wqhub://t/220900003f54", + "wqhub://t/220900003f57", + "wqhub://t/220900004c2d", + "wqhub://t/220900004ec5", + "wqhub://t/220900005379", + "wqhub://t/2209000054bb", + "wqhub://t/2209000054cb", + "wqhub://t/2209000054e3", + "wqhub://t/220900005814", + "wqhub://t/220900005824", + "wqhub://t/220900005846", + "wqhub://t/220900005ada", + "wqhub://t/220900005dd5", + "wqhub://t/220900005e6b", + "wqhub://t/22090000643b", + "wqhub://t/2209000067e7", + "wqhub://t/22090000680b", + "wqhub://t/22090000687b", + "wqhub://t/220900006b8d", + "wqhub://t/220900006ffd", + "wqhub://t/22090000772c", + "wqhub://t/220900007792", + "wqhub://t/220900007f09", + "wqhub://t/2209000080a6", + "wqhub://t/2209000080ab", + "wqhub://t/220900008964", + "wqhub://t/2209000089a6", + "wqhub://t/220900008ca8", + "wqhub://t/220900008efa", + "wqhub://t/220900008f58", + "wqhub://t/2209000091b8", + "wqhub://t/220900009c6e", + "wqhub://t/220900009eeb", + "wqhub://t/220900009f7d", + "wqhub://t/22090000bd13", + "wqhub://t/22090000c4ab", + "wqhub://t/22090000c6ad", + "wqhub://t/22090000ca5f", + "wqhub://t/22090000cadd", + "wqhub://t/22090000e3bb", + "wqhub://t/22090000eaf8", + "wqhub://t/22090000eb17", + "wqhub://t/22090000ebb0", + "wqhub://t/22090001009a", + "wqhub://t/22090001099b", + "wqhub://t/220900011d1c", + "wqhub://t/2209000127ac", + "wqhub://t/2209000128eb", + "wqhub://t/220900012959", + "wqhub://t/220900012cdb", + "wqhub://t/220900012d37", + "wqhub://t/22090001367a", + "wqhub://t/220900014eee", + "wqhub://t/2209000153d2", + "wqhub://t/220900015541", + "wqhub://t/220900015561", + "wqhub://t/220900015593", + "wqhub://t/220900015609", + "wqhub://t/220900017463", + "wqhub://t/2209000194f7", + "wqhub://t/220900019728", + "wqhub://t/220900019749", + "wqhub://t/22090001a327", + "wqhub://t/22090001ac97", + "wqhub://t/22090001accc", + "wqhub://t/22090001bbef", + "wqhub://t/22090001c14a", + "wqhub://t/22090001c2f8", + "wqhub://t/22090001c2fa", + "wqhub://t/22090001c2fc", + "wqhub://t/22090001c3e0", + "wqhub://t/22090001c41c", + "wqhub://t/22090001d247", + "wqhub://t/22090001d81c", + "wqhub://t/22090001de0f", + "wqhub://t/22090001e247", + "wqhub://t/22090001e9e8", + "wqhub://t/22090001fcab", + "wqhub://t/22090001fce4", + "wqhub://t/22090001fe70", + "wqhub://t/220900021229", + "wqhub://t/220900021261", + "wqhub://t/2209000219a8", + "wqhub://t/2209000219b0", + "wqhub://t/220900021b9c", + "wqhub://t/2209000221af", + "wqhub://t/220900023360", + "wqhub://t/220900023365", + "wqhub://t/22090002336d", + "wqhub://t/22090002336e", + "wqhub://t/220900023787", + "wqhub://t/220900023a63", + "wqhub://t/23000000055c", + "wqhub://t/2300000007b9", + "wqhub://t/230000000a82", + "wqhub://t/230000000c46", + "wqhub://t/230000000e95", + "wqhub://t/2300000011d5", + "wqhub://t/2300000013b1", + "wqhub://t/2300000014b7", + "wqhub://t/2300000014de", + "wqhub://t/23000000162a", + "wqhub://t/2300000017d6", + "wqhub://t/23000000189a", + "wqhub://t/230000002014", + "wqhub://t/23000000213e", + "wqhub://t/23000000227b", + "wqhub://t/2300000023aa", + "wqhub://t/230000002701", + "wqhub://t/2300000027bd", + "wqhub://t/230000002dec", + "wqhub://t/230000002f84", + "wqhub://t/2300000030f6", + "wqhub://t/23000000310f", + "wqhub://t/230000003647", + "wqhub://t/230000003682", + "wqhub://t/2300000038c0", + "wqhub://t/230000003c3d", + "wqhub://t/230000003c57", + "wqhub://t/230000003c98", + "wqhub://t/230000003ca1", + "wqhub://t/230000003ca8", + "wqhub://t/230000003cac", + "wqhub://t/230000003cad", + "wqhub://t/230000003cb9", + "wqhub://t/230000003cca", + "wqhub://t/230000003cdb", + "wqhub://t/230000003ce3", + "wqhub://t/230000003d0a", + "wqhub://t/230000003df8", + "wqhub://t/230000003e17", + "wqhub://t/230000003e3b", + "wqhub://t/23000000405f", + "wqhub://t/230000004223", + "wqhub://t/230000004662", + "wqhub://t/230000004c84", + "wqhub://t/230000004f51", + "wqhub://t/230000005220", + "wqhub://t/2300000052e2", + "wqhub://t/2300000053f3", + "wqhub://t/2300000056db", + "wqhub://t/2300000058d8", + "wqhub://t/230000005a13", + "wqhub://t/2300000066d9", + "wqhub://t/230000006722", + "wqhub://t/2300000067cf", + "wqhub://t/230000006bda", + "wqhub://t/230000006c97", + "wqhub://t/230000007927", + "wqhub://t/230000007a5f", + "wqhub://t/230000007b58", + "wqhub://t/230000007c1d", + "wqhub://t/230000007d42", + "wqhub://t/230000008168", + "wqhub://t/230000008459", + "wqhub://t/2300000087b7", + "wqhub://t/230000008966", + "wqhub://t/230000008a24", + "wqhub://t/230000008af0", + "wqhub://t/230000008b01", + "wqhub://t/230000008bd7", + "wqhub://t/230000008cb1", + "wqhub://t/230000008d25", + "wqhub://t/230000009109", + "wqhub://t/23000000915e", + "wqhub://t/230000009498", + "wqhub://t/230000009744", + "wqhub://t/230000009a04", + "wqhub://t/23000000a083", + "wqhub://t/23000000a08a", + "wqhub://t/23000000b001", + "wqhub://t/23000000b0e1", + "wqhub://t/23000000b593", + "wqhub://t/23000000bbff", + "wqhub://t/23000000bd14", + "wqhub://t/23000000ccfb", + "wqhub://t/23000000d502", + "wqhub://t/23000000d5c4", + "wqhub://t/23000000df10", + "wqhub://t/23000000e012", + "wqhub://t/23000000e3d5", + "wqhub://t/23000000e90d", + "wqhub://t/23000000e998", + "wqhub://t/23000000e9d4", + "wqhub://t/23000000e9e2", + "wqhub://t/23000000f130", + "wqhub://t/23000000f400", + "wqhub://t/23000000f474", + "wqhub://t/23000000f490", + "wqhub://t/23000000f737", + "wqhub://t/23000000f962", + "wqhub://t/23000000f997", + "wqhub://t/23000000fb41", + "wqhub://t/23000001018e", + "wqhub://t/230000010291", + "wqhub://t/230000010514", + "wqhub://t/230000010d2b", + "wqhub://t/230000010d3d", + "wqhub://t/23000001160a", + "wqhub://t/2300000121f5", + "wqhub://t/23000001259a", + "wqhub://t/230000012849", + "wqhub://t/230000012e19", + "wqhub://t/230000014097", + "wqhub://t/23000001415a", + "wqhub://t/2300000141b0", + "wqhub://t/23000001426e", + "wqhub://t/2300000154e0", + "wqhub://t/2300000154f4", + "wqhub://t/230000015511", + "wqhub://t/23000001553a", + "wqhub://t/230000015669", + "wqhub://t/23000001568a", + "wqhub://t/230000016398", + "wqhub://t/230000016721", + "wqhub://t/23000001684e", + "wqhub://t/230000016ed0", + "wqhub://t/230000017099", + "wqhub://t/230000017215", + "wqhub://t/23000001734f", + "wqhub://t/23000001743d", + "wqhub://t/2300000174d1", + "wqhub://t/230000017524", + "wqhub://t/23000001753f", + "wqhub://t/23000001754a", + "wqhub://t/230000017553", + "wqhub://t/2300000181f5", + "wqhub://t/230000018992", + "wqhub://t/230000018a98", + "wqhub://t/2300000194de", + "wqhub://t/230000019585", + "wqhub://t/23000001974b", + "wqhub://t/230000019754", + "wqhub://t/23000001989c", + "wqhub://t/230000019e1c", + "wqhub://t/23000001a53f", + "wqhub://t/23000001a63a", + "wqhub://t/23000001a985", + "wqhub://t/23000001ab50", + "wqhub://t/23000001ab5d", + "wqhub://t/23000001ab84", + "wqhub://t/23000001aea0", + "wqhub://t/23000001b371", + "wqhub://t/23000001bc6b", + "wqhub://t/23000001bd4f", + "wqhub://t/23000001c3c3", + "wqhub://t/23000001c421", + "wqhub://t/23000001c422", + "wqhub://t/23000001d6cf", + "wqhub://t/23000001d990", + "wqhub://t/23000001e199", + "wqhub://t/23000001eda0", + "wqhub://t/23000001f8d9", + "wqhub://t/2300000201b2", + "wqhub://t/2300000201b6", + "wqhub://t/2300000201f0", + "wqhub://t/2300000202b3", + "wqhub://t/2300000202b5", + "wqhub://t/2300000202b8", + "wqhub://t/2300000202c3", + "wqhub://t/230000020360", + "wqhub://t/23000002099d", + "wqhub://t/23000002099f", + "wqhub://t/2300000209a3", + "wqhub://t/230000020a64", + "wqhub://t/230000020a8b", + "wqhub://t/230000020cc6", + "wqhub://t/230000021589", + "wqhub://t/2300000215de", + "wqhub://t/230000021651", + "wqhub://t/230000023077", + "wqhub://t/23000002375e", + "wqhub://t/23000002550b", + "wqhub://t/230000025c1b", + "wqhub://t/230000025c1f", + "wqhub://t/230000026855", + "wqhub://t/230000026f58", + "wqhub://t/230000026ff7", + "wqhub://t/2301000052b8", + "wqhub://t/230100009848", + "wqhub://t/230100009d9a", + "wqhub://t/23010000a0d0", + "wqhub://t/2301000111f0", + "wqhub://t/230100011cda", + "wqhub://t/230100011d15", + "wqhub://t/230100017d57", + "wqhub://t/23010001ae1e", + "wqhub://t/23010001c2cd", + "wqhub://t/2301000254b2", + "wqhub://t/2302000090e5", + "wqhub://t/2303000011c9", + "wqhub://t/23050001ce65", + "wqhub://t/2306000040ef", + "wqhub://t/23060001afba", + "wqhub://t/2307000046e3", + "wqhub://t/2307000054ee", + "wqhub://t/230700005f8e", + "wqhub://t/230700005fb1", + "wqhub://t/2307000061d0", + "wqhub://t/230700008478", + "wqhub://t/23070000b269", + "wqhub://t/230700019e1b", + "wqhub://t/230700019f8f", + "wqhub://t/23070001af14", + "wqhub://t/230700021915", + "wqhub://t/230700023b39", + "wqhub://t/2309000014ea", + "wqhub://t/23090000162c", + "wqhub://t/230900001899", + "wqhub://t/2309000018b3", + "wqhub://t/2309000018e0", + "wqhub://t/2309000022e5", + "wqhub://t/230900002aa5", + "wqhub://t/230900002e54", + "wqhub://t/230900002e6d", + "wqhub://t/2309000038c1", + "wqhub://t/2309000038c6", + "wqhub://t/230900003e9f", + "wqhub://t/230900004af6", + "wqhub://t/230900004d25", + "wqhub://t/230900004d3f", + "wqhub://t/230900004ed2", + "wqhub://t/230900005022", + "wqhub://t/23090000548e", + "wqhub://t/2309000054eb", + "wqhub://t/230900005509", + "wqhub://t/230900005525", + "wqhub://t/230900005528", + "wqhub://t/230900005812", + "wqhub://t/230900005813", + "wqhub://t/230900005815", + "wqhub://t/230900005818", + "wqhub://t/23090000581a", + "wqhub://t/230900005827", + "wqhub://t/230900005828", + "wqhub://t/230900005829", + "wqhub://t/23090000582d", + "wqhub://t/23090000582e", + "wqhub://t/230900005844", + "wqhub://t/230900005848", + "wqhub://t/230900005849", + "wqhub://t/23090000584a", + "wqhub://t/23090000584b", + "wqhub://t/23090000585b", + "wqhub://t/23090000585c", + "wqhub://t/23090000585e", + "wqhub://t/23090000585f", + "wqhub://t/230900005860", + "wqhub://t/230900005861", + "wqhub://t/230900005865", + "wqhub://t/230900005868", + "wqhub://t/230900005881", + "wqhub://t/230900005883", + "wqhub://t/230900005885", + "wqhub://t/230900005886", + "wqhub://t/2309000058aa", + "wqhub://t/230900005e51", + "wqhub://t/230900005e54", + "wqhub://t/230900005e55", + "wqhub://t/230900005eed", + "wqhub://t/230900005f83", + "wqhub://t/230900005f93", + "wqhub://t/230900005fa5", + "wqhub://t/23090000669c", + "wqhub://t/230900006dce", + "wqhub://t/230900006f83", + "wqhub://t/230900007184", + "wqhub://t/2309000072a1", + "wqhub://t/230900007347", + "wqhub://t/230900007635", + "wqhub://t/230900007d22", + "wqhub://t/23090000810e", + "wqhub://t/230900008144", + "wqhub://t/230900008839", + "wqhub://t/230900008cc8", + "wqhub://t/230900008e79", + "wqhub://t/230900008f26", + "wqhub://t/2309000091b2", + "wqhub://t/230900009565", + "wqhub://t/23090000993d", + "wqhub://t/230900009b74", + "wqhub://t/230900009c21", + "wqhub://t/230900009d37", + "wqhub://t/230900009db8", + "wqhub://t/230900009e4e", + "wqhub://t/23090000b429", + "wqhub://t/23090000da2d", + "wqhub://t/23090000e3dd", + "wqhub://t/23090000ef47", + "wqhub://t/23090000f34a", + "wqhub://t/230900010261", + "wqhub://t/230900010329", + "wqhub://t/230900010440", + "wqhub://t/230900010455", + "wqhub://t/230900010457", + "wqhub://t/2309000105b2", + "wqhub://t/230900010f32", + "wqhub://t/230900010f3e", + "wqhub://t/230900011062", + "wqhub://t/230900011c4e", + "wqhub://t/230900011e6d", + "wqhub://t/2309000128e1", + "wqhub://t/2309000128e4", + "wqhub://t/230900012c23", + "wqhub://t/230900013a10", + "wqhub://t/230900015513", + "wqhub://t/2309000155db", + "wqhub://t/2309000155df", + "wqhub://t/23090001566b", + "wqhub://t/2309000156da", + "wqhub://t/23090001583b", + "wqhub://t/2309000177ef", + "wqhub://t/2309000181a9", + "wqhub://t/230900019611", + "wqhub://t/23090001975e", + "wqhub://t/23090001a963", + "wqhub://t/23090001ab75", + "wqhub://t/23090001ad9f", + "wqhub://t/23090001adf2", + "wqhub://t/23090001aea4", + "wqhub://t/23090001aec5", + "wqhub://t/23090001bd25", + "wqhub://t/23090001bd29", + "wqhub://t/23090001bd56", + "wqhub://t/23090001bd82", + "wqhub://t/23090001bdcb", + "wqhub://t/23090001c102", + "wqhub://t/23090001c14c", + "wqhub://t/23090001c159", + "wqhub://t/23090001c15f", + "wqhub://t/23090001c163", + "wqhub://t/23090001c167", + "wqhub://t/23090001c1d3", + "wqhub://t/23090001c21b", + "wqhub://t/23090001c30d", + "wqhub://t/23090001c31a", + "wqhub://t/23090001c356", + "wqhub://t/23090001c361", + "wqhub://t/23090001c36c", + "wqhub://t/23090001c372", + "wqhub://t/23090001c377", + "wqhub://t/23090001c37b", + "wqhub://t/23090001cbb0", + "wqhub://t/23090001dafc", + "wqhub://t/23090001db92", + "wqhub://t/23090001dba1", + "wqhub://t/23090001dbb0", + "wqhub://t/23090001dbb5", + "wqhub://t/23090001e1fa", + "wqhub://t/23090001e200", + "wqhub://t/23090001e201", + "wqhub://t/23090001e207", + "wqhub://t/23090001e20f", + "wqhub://t/23090001e985", + "wqhub://t/23090001fa72", + "wqhub://t/230900020493", + "wqhub://t/2309000204d0", + "wqhub://t/23090002092f", + "wqhub://t/230900020b49", + "wqhub://t/2309000210c4", + "wqhub://t/23090002164d", + "wqhub://t/230900022104", + "wqhub://t/230900022770", + "wqhub://t/230900023361", + "wqhub://t/230900023364", + "wqhub://t/230900023366", + "wqhub://t/230900023367", + "wqhub://t/230900023368", + "wqhub://t/230900023369", + "wqhub://t/23090002336a", + "wqhub://t/23090002336b", + "wqhub://t/23090002336c", + "wqhub://t/23090002336f", + "wqhub://t/230900023370", + "wqhub://t/230900023371", + "wqhub://t/230900023372", + "wqhub://t/230900023373", + "wqhub://t/230900023377", + "wqhub://t/230900023378", + "wqhub://t/230900023379", + "wqhub://t/23090002542b", + "wqhub://t/240000000c40", + "wqhub://t/240000000c5e", + "wqhub://t/2400000013f5", + "wqhub://t/240000001669", + "wqhub://t/24000000168e", + "wqhub://t/2400000018ec", + "wqhub://t/240000001ecd", + "wqhub://t/2400000026ea", + "wqhub://t/24000000279a", + "wqhub://t/240000002c25", + "wqhub://t/240000002e2d", + "wqhub://t/240000003dd2", + "wqhub://t/2400000044ce", + "wqhub://t/240000004622", + "wqhub://t/24000000467d", + "wqhub://t/240000004b56", + "wqhub://t/240000007c10", + "wqhub://t/240000007d72", + "wqhub://t/240000007f7d", + "wqhub://t/240000009f77", + "wqhub://t/240000009f78", + "wqhub://t/24000000abf0", + "wqhub://t/24000000bda9", + "wqhub://t/24000000e3d1", + "wqhub://t/24000000eb0f", + "wqhub://t/24000000eb36", + "wqhub://t/2400000121e0", + "wqhub://t/2400000153d4", + "wqhub://t/240000015847", + "wqhub://t/240000015d1c", + "wqhub://t/240000015f7f", + "wqhub://t/2400000166c9", + "wqhub://t/240000016b8c", + "wqhub://t/2400000172ec", + "wqhub://t/24000001752f", + "wqhub://t/240000017559", + "wqhub://t/240000019ee8", + "wqhub://t/24000001a1a3", + "wqhub://t/24000001ab46", + "wqhub://t/24000001ab5b", + "wqhub://t/24000001b2d5", + "wqhub://t/24000001bdea", + "wqhub://t/24000001cd5d", + "wqhub://t/24000001e08a", + "wqhub://t/24000001e138", + "wqhub://t/240000020225", + "wqhub://t/2400000202b9", + "wqhub://t/2400000202c2", + "wqhub://t/2400000202c4", + "wqhub://t/2400000202ef", + "wqhub://t/2400000204c6", + "wqhub://t/2400000204ca", + "wqhub://t/2400000212fb", + "wqhub://t/240000025c1c", + "wqhub://t/240000026677", + "wqhub://t/2400000268bd", + "wqhub://t/240100016a39", + "wqhub://t/240700004ec9", + "wqhub://t/240900000c6b", + "wqhub://t/240900001805", + "wqhub://t/24090000582f", + "wqhub://t/240900005866", + "wqhub://t/240900005867", + "wqhub://t/240900005f96", + "wqhub://t/240900008105", + "wqhub://t/24090000810b", + "wqhub://t/2409000083c4", + "wqhub://t/24090000850e", + "wqhub://t/240900009189", + "wqhub://t/240900009b8b", + "wqhub://t/24090000a806", + "wqhub://t/24090000b048", + "wqhub://t/24090000e62e", + "wqhub://t/24090001045a", + "wqhub://t/240900011dc6", + "wqhub://t/240900014671", + "wqhub://t/2409000154fd", + "wqhub://t/240900015666", + "wqhub://t/240900015679", + "wqhub://t/240900015dfe", + "wqhub://t/240900016208", + "wqhub://t/24090001ae9e", + "wqhub://t/24090001c37e", + "wqhub://t/24090001e136", + "wqhub://t/24090001e85e", + "wqhub://t/24090001f89d", + "wqhub://t/240900023374", + "wqhub://t/240900023375", + "wqhub://t/240900026444", + ].map((u) => TaskRef.ofUri(u))); + + settings.setVersionPatchStatus(version, true); +} diff --git a/lib/weiqi_hub_app.dart b/lib/weiqi_hub_app.dart index 0f8b8b66..3946a849 100644 --- a/lib/weiqi_hub_app.dart +++ b/lib/weiqi_hub_app.dart @@ -1,5 +1,9 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/main_page.dart'; +import 'package:wqhub/routes.dart'; import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; class WeiqiHubApp extends StatefulWidget { @@ -12,23 +16,44 @@ class WeiqiHubApp extends StatefulWidget { class _WeiqiHubAppState extends State { @override Widget build(BuildContext context) { - final lightTheme = ThemeData( - colorSchemeSeed: Colors.blueAccent, - brightness: Brightness.light, - ); - final darkTheme = ThemeData( - colorSchemeSeed: Colors.blueAccent, - brightness: Brightness.dark, - ); return MaterialApp( title: 'WeiqiHub', - theme: lightTheme, - darkTheme: darkTheme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: _defaultToCN(context.settings.locale), + theme: _buildTheme(context.settings.locale, Brightness.light), + darkTheme: _buildTheme(context.settings.locale, Brightness.dark), themeMode: context.settings.themeMode, - home: MainPage(reloadAppTheme: () { - setState(() {}); - }), + home: MainPage( + destination: MainPageDestination.home, + rebuildApp: () { + setState(() {}); + }, + ), + routes: routes, + onGenerateRoute: onGenerateRoute, + navigatorObservers: [routeObserver], debugShowCheckedModeBanner: false, ); } + + ThemeData _buildTheme(Locale locale, Brightness brightness) { + final isWinChinese = Platform.isWindows && locale.languageCode == 'zh'; + final fontFamily = isWinChinese ? '微软雅黑' : null; + final fontFamilyFallback = isWinChinese ? ['Microsoft YaHei'] : null; + return ThemeData( + colorSchemeSeed: Colors.blueAccent, + brightness: brightness, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + ); + } + + Locale _defaultToCN(Locale loc) { + if (loc.languageCode == 'zh' && loc.countryCode == null) { + // This is to avoid font issues on Windows: https://github.com/flutter/flutter/issues/103811#issuecomment-1199012026 + return Locale('zh', 'CN'); + } + return loc; + } } diff --git a/lib/wq/grid.dart b/lib/wq/grid.dart new file mode 100644 index 00000000..e4b00ab9 --- /dev/null +++ b/lib/wq/grid.dart @@ -0,0 +1,16 @@ +// Some utilities for working with Lists of Lists. + +List> generate2D(int boardSize, T Function(int i, int j) generator) { + return List.generate( + boardSize, (i) => List.generate(boardSize, (j) => generator(i, j))); +} + +Map count2D(List> grid) { + final Map counts = {}; + for (final row in grid) { + for (final item in row) { + counts[item] = (counts[item] ?? 0) + 1; + } + } + return counts; +} diff --git a/lib/wq/rank.dart b/lib/wq/rank.dart index c3a1139c..699aae82 100644 --- a/lib/wq/rank.dart +++ b/lib/wq/rank.dart @@ -1,4 +1,4 @@ -enum Rank { +enum Rank implements Comparable { k30, k29, k28, @@ -71,4 +71,14 @@ enum Rank { } return '${(index - d10.index + frac).toStringAsFixed(1)}P'; } + + @override + int compareTo(Rank other) => index - other.index; +} + +extension RankComparisonOperators on Rank { + bool operator <(Rank other) => compareTo(other) < 0; + bool operator <=(Rank other) => compareTo(other) <= 0; + bool operator >(Rank other) => compareTo(other) > 0; + bool operator >=(Rank other) => compareTo(other) >= 0; } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5d35d5c1..f01e9ace 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import device_info_plus +import file_picker import package_info_plus import path_provider_foundation import share_plus @@ -13,6 +15,8 @@ import sqlite3_flutter_libs import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 08c3ab17..681e73fe 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index ee95ab7e..43a8b8c7 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/pubspec.yaml b/pubspec.yaml index 4ead99b8..4158c147 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: wqhub -description: "Play and train weiqi." +description: "A unified client to multiple Go servers and offline puzzle solving." # 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 @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # 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: 0.1.8+20 +version: 0.1.10+25 environment: sdk: ^3.6.0 @@ -33,11 +33,15 @@ dependencies: convert: ^3.1.2 crypto: ^3.0.6 cupertino_icons: ^1.0.8 + device_info_plus: ^11.5.0 extension_type_unions: ^1.0.10 fast_immutable_collections: ^11.0.3 + file_picker: ^10.3.3 fixnum: ^1.1.1 flutter: sdk: flutter + flutter_localizations: + sdk: flutter flutter_slidable: ^4.0.0 flutter_soloud: ^3.0.0 http: ^1.3.0 @@ -47,18 +51,20 @@ dependencies: path: ^1.9.0 path_provider: ^2.1.5 petitparser: ^6.0.2 - protobuf: ^3.1.0 - share_plus: ^10.1.4 + protobuf: ^4.1.1 + share_plus: ^11.0.0 shared_preferences: ^2.3.5 sqlite3: ^2.7.5 sqlite3_flutter_libs: ^0.5.32 url_launcher: ^6.3.1 + uuid: ^4.5.1 + web_socket_channel: ^3.0.0 dev_dependencies: flutter_test: sdk: flutter flutter_launcher_icons: ^0.14.3 - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter_launcher_icons: android: "launcher_icon" @@ -78,6 +84,7 @@ flutter_launcher_icons: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in @@ -92,6 +99,7 @@ flutter: - assets/sounds/ - assets/sounds/en/ - assets/tasks/ + - assets/models/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/grid_test.dart b/test/grid_test.dart new file mode 100644 index 00000000..0e9c0e1b --- /dev/null +++ b/test/grid_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wqhub/wq/grid.dart'; + +void main() { + group('generate2D', () { + test('creates correct size grid with generator function', () { + final grid = generate2D(3, (i, j) => '$i,$j'); + expect(grid.length, 3); + expect(grid[0].length, 3); + expect(grid[0][0], '0,0'); + expect(grid[1][1], '1,1'); + expect(grid[2][2], '2,2'); + }); + + test('handles edge case of empty grid', () { + final grid = generate2D(0, (i, j) => 'never called'); + expect(grid.length, 0); + }); + }); + + group('count2D', () { + test('counts different elements including nulls', () { + final grid = [ + ['A', 'B', null], + ['B', 'A', 'A'], + [null, 'B', 'A'] + ]; + + final counts = count2D(grid); + expect(counts['A'], 4); + expect(counts['B'], 3); + expect(counts[null], 2); + }); + + test('handles empty grid', () { + final List> grid = []; + final counts = count2D(grid); + expect(counts.isEmpty, true); + }); + }); +} diff --git a/test/ogs_websocket_test.dart b/test/ogs_websocket_test.dart new file mode 100644 index 00000000..81708e2a --- /dev/null +++ b/test/ogs_websocket_test.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:wqhub/game_client/ogs/ogs_websocket_manager.dart'; + +void main() { + group('OGSWebSocketManager sendAndGetResponse', () { + test('success case - server responds with data', () async { + // Create StreamControllers for bidirectional communication + final serverToClient = StreamController(); + final clientToServer = StreamController(); + + // Create a StreamChannel using the controllers + final mockChannel = + StreamChannel(serverToClient.stream, clientToServer.sink); + + // Create the manager with custom channel factory + final manager = OGSWebSocketManager( + serverUrl: 'wss://online-go.com', + createChannel: (_) => mockChannel, + ); + + // Connect to establish the channel + await manager.connect(); + + // Arrange + const command = 'test/command'; + final data = {'test': 'data'}; + final expectedResponse = {'result': 'success', 'value': 42}; + + // Start the request + final responseFuture = manager.sendAndGetResponse(command, data); + + // Capture the sent message and extract request ID + final sentMessage = await clientToServer.stream.first; + final sentData = jsonDecode(sentMessage.toString()); + final requestId = sentData[2] as int; + + // Simulate server response - OGS format: [requestId, responseData, null] + final serverResponse = [requestId, expectedResponse, null]; + serverToClient.add(jsonEncode(serverResponse)); + + // Assert + expect(await responseFuture, expectedResponse); + + // Clean up + await manager.disconnect(); + await serverToClient.close(); + await clientToServer.close(); + manager.dispose(); + }); + + test('error case - server responds with error', () async { + // Create StreamControllers for bidirectional communication + final serverToClient = StreamController(); + final clientToServer = StreamController(); + + // Create a StreamChannel using the controllers + final mockChannel = + StreamChannel(serverToClient.stream, clientToServer.sink); + + // Create the manager with custom channel factory + final manager = OGSWebSocketManager( + serverUrl: 'wss://online-go.com', + createChannel: (_) => mockChannel, + ); + + // Connect to establish the channel + await manager.connect(); + + // Arrange + const command = 'invalid/command'; + final data = {'invalid': 'data'}; + final expectedError = 'Command not found'; + + // Start the request + final responseFuture = manager.sendAndGetResponse(command, data); + + // Capture the sent message and extract request ID + final sentMessage = await clientToServer.stream.first; + final sentData = jsonDecode(sentMessage.toString()); + final requestId = sentData[2] as int; + + // Simulate server error response + final serverResponse = [requestId, null, expectedError]; + serverToClient.add(jsonEncode(serverResponse)); + + // Assert + await expectLater(responseFuture, throwsA(equals(expectedError))); + + // Clean up + await manager.disconnect(); + await serverToClient.close(); + await clientToServer.close(); + manager.dispose(); + }); + }); +} diff --git a/test/sgf_test.dart b/test/sgf_test.dart index f17efbd5..310b36bb 100644 --- a/test/sgf_test.dart +++ b/test/sgf_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:wqhub/game_client/game_record.dart'; import 'package:wqhub/parse/sgf/sgf.dart'; +import 'package:wqhub/wq/wq.dart' as wq; void main() { test('parse', () { @@ -32,4 +33,86 @@ void main() { expect(rec.type, GameRecordType.sgf); expect(rec.moves.length, 291); }); + + test('fromSgf with variations', () { + // Test SGF with a simple variation - should follow main line + const sgfData = + '(;GM[1]FF[4]SZ[9];B[fe];W[de](;B[ec];W[dc];B[db])(;B[ee];W[dd]))'; + final sgf = Sgf.parse(sgfData); + + expect(sgf.trees.length, 1); + + final tree = sgf.trees.first; + // Main line nodes: root + B[fe] + W[de] + expect(tree.nodes.length, 3); + expect(tree.nodes[0]['GM'], ['1']); + expect(tree.nodes[1]['B'], ['fe']); + expect(tree.nodes[2]['W'], ['de']); + + // There are two variations (children) + expect(tree.children.length, 2); + + // First variation + final var1 = tree.children[0]; + expect(var1.nodes.length, 3); + expect(var1.nodes[0]['B'], ['ec']); + expect(var1.nodes[1]['W'], ['dc']); + expect(var1.nodes[2]['B'], ['db']); + + // Second variation + final var2 = tree.children[1]; + expect(var2.nodes.length, 2); + expect(var2.nodes[0]['B'], ['ee']); + expect(var2.nodes[1]['W'], ['dd']); + + final rec = GameRecord.fromSgf(sgfData); + // We assume the actual game follows the first variation + // at each branch point. Therefore, we have + // 2 moves at the top level + 3 moves in the first variation. + expect(rec.moves.length, 5); + }); + + test('SGF with escaped bracket', () { + // Test that escaped characters in comments are handled correctly + const sgfWithEscape = '(;GM[1]FF[4]SZ[9]C[Comment with \\] bracket];B[aa])'; + final sgf = Sgf.parse(sgfWithEscape); + expect(sgf.trees.length, 1); + + final comment = sgf.trees.first.nodes.first['C']?.first; + expect(comment, 'Comment with ] bracket'); + + final rec = GameRecord.fromSgf(sgfWithEscape); + expect(rec.moves.length, 1); + expect(rec.moves[0].col, wq.Color.black); + }); + + test('parse with variations... and a space', () { + const sgfWithSpaceAroundVariation = + '(;GM[1]FF[4]SZ[9];B[fe];W[de] (;B[ec];W[dc];B[db])(;B[ee];W[dd]))'; + final sgf = Sgf.parse(sgfWithSpaceAroundVariation); + expect(sgf.trees.length, 1); + + final tree = sgf.trees.first; + // Main line nodes: root + B[fe] + W[de] + expect(tree.nodes.length, 3); + expect(tree.nodes[0]['GM'], ['1']); + expect(tree.nodes[1]['B'], ['fe']); + expect(tree.nodes[2]['W'], ['de']); + + // There are two variations (children) + expect(tree.children.length, 2); + + // First variation + final var1 = tree.children[0]; + expect(var1.nodes.length, 3); + expect(var1.nodes[0]['B'], ['ec']); + expect(var1.nodes[1]['W'], ['dc']); + expect(var1.nodes[2]['B'], ['db']); + + // Second variation + final var2 = tree.children[1]; + expect(var2.nodes.length, 2); + expect(var2.nodes[0]['B'], ['ee']); + expect(var2.nodes[1]['W'], ['dd']); + }); } diff --git a/test/task_tag_test.dart b/test/task_tag_test.dart new file mode 100644 index 00000000..b76694e7 --- /dev/null +++ b/test/task_tag_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wqhub/train/task_tag.dart'; + +void main() { + test('tag rank range consistency', () { + for (final tag in TaskTag.values) { + for (final rankRange in tag.ranks()) { + expect(rankRange.from.index <= rankRange.to.index, true, + reason: 'tag $tag: bad range $rankRange'); + } + } + }); +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 758721c0..dc6c5d04 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -106,3 +106,4 @@ install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" CONFIGURATIONS Profile;Release COMPONENT Runtime) + \ No newline at end of file diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt old mode 100644 new mode 100755 index 903f4899..efb62ebe --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -1,109 +1,109 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# Set fallback configurations for older versions of the flutter tool. -if (NOT DEFINED FLUTTER_TARGET_PLATFORM) - set(FLUTTER_TARGET_PLATFORM "windows-x64") -endif() - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc old mode 100644 new mode 100755 diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h old mode 100644 new mode 100755 diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake old mode 100644 new mode 100755 diff --git a/windows/setup.iss b/windows/setup.iss index 7fcf1c30..6feac084 100755 --- a/windows/setup.iss +++ b/windows/setup.iss @@ -1,6 +1,6 @@ [Setup] AppName=WeiqiHub -AppVersion=0.1.3 +AppVersion=0.1.9 AppPublisher=WalrusWQ AppPublisherURL=https://walruswq.com WizardStyle=modern @@ -24,9 +24,9 @@ end; Source: "x64/runner/Release/wqhub.exe"; DestDir: "{app}"; DestName: "WeiqiHub.exe" Source: "x64/runner/Release/*.dll"; DestDir: "{app}" Source: "x64/runner/Release/data/*"; DestDir: "{app}/data"; Flags: recursesubdirs -Source: "{code:GetVCRedistDir}\x64\Microsoft.VC143.CRT\msvcp140.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "{code:GetVCRedistDir}\x64\Microsoft.VC143.CRT\vcruntime140.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "{code:GetVCRedistDir}\x64\Microsoft.VC143.CRT\vcruntime140_1.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#VCRedistPath}\msvcp140.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#VCRedistPath}\vcruntime140.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#VCRedistPath}\vcruntime140_1.dll"; DestDir: "{app}"; Flags: ignoreversion [Icons] Name: "{group}\WeiqiHub"; Filename: "{app}\WeiqiHub.exe"