diff --git a/.github/workflows/build_linux.yaml b/.github/workflows/build_linux.yaml index 7aba83cd..86b961a9 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,10 +9,15 @@ 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 - sudo apt-get install -y ninja-build libgtk-3-dev libasound2-dev binutils coreutils desktop-file-utils fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-setuptools squashfs-tools strace util-linux zsync + sudo apt-get install -y ninja-build libgtk-3-dev libasound2-dev binutils coreutils desktop-file-utils fakeroot libfuse2 libgdk-pixbuf2.0-dev patchelf python3-pip python3-setuptools squashfs-tools strace util-linux zsync - name: Setup appimagetool run: | sudo wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /opt/appimagetool @@ -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/.gitignore b/.gitignore index bd84e915..9dea0cca 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ out/ # Flutter platform-specific dirs web/ + +# Localization +untranslated-messages.txt 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..5ac9e5be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,47 @@ # Changelog +## 0.1.11 +- OGS support (@benjaminpjones) +- add setting to track Time Frenzy mistakes (@hemme) +- add option to copy task SGF (@hemme) +- accessibility: add setting to show wrong moves as crosses (@hemme) +- Italian localization (@hemme) +- Romanian localization (@adudenamedruby) +- German localization (@StHagel, @InfoKendoKing) +- next task can be triggered with a swipe gesture where applicable +- add setting for randomizing task orientation (@hemme) + +## 0.1.10 +- now available in Chinese (simplified), Russian and Spanish +- add task search by pattern +- add help dialogs for several pages +- show current rank for each topic +- fix topic progress display bug when returning to topic page +- fix timezone bug in statistics +- new theme: BadukTV +- remove experimental 9x9 human-like AI bot +- remove a few broken tasks + +## 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..cad01343 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,35 @@ [![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. ## Features - Local board -- Play on Fox Weiqi (unofficial client) -- Play on Tygem (unofficial client) -- Solve tsumego (no internet required): +- Play: + * Fox Weiqi (unofficial client) + * Tygem (unofficial client) + * OGS +- Train (no internet required): * Grading Exam: sets of 10 problems of the same rank, each with a 45s time limit. Solve at least 8 to pass (a.k.a. "guan"). * Endgame Exam: sets of 10 endgame problems of the same rank, each with a 45s time limit. Solve at least 8 to pass. * 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..3c85cfe0 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.7-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index a42444de..4f520718 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.2.1" apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false + id "com.android.application" version "8.6.0" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" 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/tasks/15_0.bin b/assets/tasks/15_0.bin index bdcbfcf7..828d8bf7 100644 Binary files a/assets/tasks/15_0.bin and b/assets/tasks/15_0.bin differ diff --git a/assets/tasks/15_0.idx b/assets/tasks/15_0.idx new file mode 100644 index 00000000..3bc7e280 Binary files /dev/null and b/assets/tasks/15_0.idx differ diff --git a/assets/tasks/15_1.bin b/assets/tasks/15_1.bin index 1daad54d..0b4ec453 100644 Binary files a/assets/tasks/15_1.bin and b/assets/tasks/15_1.bin differ diff --git a/assets/tasks/15_1.idx b/assets/tasks/15_1.idx new file mode 100644 index 00000000..304e0929 Binary files /dev/null and b/assets/tasks/15_1.idx differ diff --git a/assets/tasks/15_2.bin b/assets/tasks/15_2.bin index 4e21166f..d6e80d6d 100644 Binary files a/assets/tasks/15_2.bin and b/assets/tasks/15_2.bin differ diff --git a/assets/tasks/15_2.idx b/assets/tasks/15_2.idx new file mode 100644 index 00000000..33b1fbfb Binary files /dev/null and b/assets/tasks/15_2.idx differ diff --git a/assets/tasks/15_3.bin b/assets/tasks/15_3.bin index ed324fee..03b8bce2 100644 Binary files a/assets/tasks/15_3.bin and b/assets/tasks/15_3.bin differ diff --git a/assets/tasks/15_3.idx b/assets/tasks/15_3.idx new file mode 100644 index 00000000..dc3103b3 Binary files /dev/null and b/assets/tasks/15_3.idx differ diff --git a/assets/tasks/15_4.idx b/assets/tasks/15_4.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/15_5.idx b/assets/tasks/15_5.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/15_6.idx b/assets/tasks/15_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/15_7.bin b/assets/tasks/15_7.bin index 3b9a32fc..b5fd927b 100644 Binary files a/assets/tasks/15_7.bin and b/assets/tasks/15_7.bin differ diff --git a/assets/tasks/15_7.idx b/assets/tasks/15_7.idx new file mode 100644 index 00000000..6e699f18 Binary files /dev/null and b/assets/tasks/15_7.idx differ diff --git a/assets/tasks/15_8.bin b/assets/tasks/15_8.bin index 0964aad0..9342a68f 100644 Binary files a/assets/tasks/15_8.bin and b/assets/tasks/15_8.bin differ diff --git a/assets/tasks/15_8.idx b/assets/tasks/15_8.idx new file mode 100644 index 00000000..5fde739b Binary files /dev/null and b/assets/tasks/15_8.idx differ diff --git a/assets/tasks/15_9.bin b/assets/tasks/15_9.bin index 88553e25..c82e0072 100644 Binary files a/assets/tasks/15_9.bin and b/assets/tasks/15_9.bin differ diff --git a/assets/tasks/15_9.idx b/assets/tasks/15_9.idx new file mode 100644 index 00000000..4f7b6507 Binary files /dev/null and b/assets/tasks/15_9.idx differ diff --git a/assets/tasks/16_0.bin b/assets/tasks/16_0.bin index 7cc345c7..0d497d95 100644 Binary files a/assets/tasks/16_0.bin and b/assets/tasks/16_0.bin differ diff --git a/assets/tasks/16_0.idx b/assets/tasks/16_0.idx new file mode 100644 index 00000000..99652b70 Binary files /dev/null and b/assets/tasks/16_0.idx differ diff --git a/assets/tasks/16_1.bin b/assets/tasks/16_1.bin index 7eed617b..5f8aedf9 100644 Binary files a/assets/tasks/16_1.bin and b/assets/tasks/16_1.bin differ diff --git a/assets/tasks/16_1.idx b/assets/tasks/16_1.idx new file mode 100644 index 00000000..cb2f7f09 Binary files /dev/null and b/assets/tasks/16_1.idx differ diff --git a/assets/tasks/16_2.bin b/assets/tasks/16_2.bin index 0d1db89e..3fd25aed 100644 Binary files a/assets/tasks/16_2.bin and b/assets/tasks/16_2.bin differ diff --git a/assets/tasks/16_2.idx b/assets/tasks/16_2.idx new file mode 100644 index 00000000..27836bf0 Binary files /dev/null and b/assets/tasks/16_2.idx differ diff --git a/assets/tasks/16_3.bin b/assets/tasks/16_3.bin index f3dea001..c8c207f0 100644 Binary files a/assets/tasks/16_3.bin and b/assets/tasks/16_3.bin differ diff --git a/assets/tasks/16_3.idx b/assets/tasks/16_3.idx new file mode 100644 index 00000000..8d06e35b Binary files /dev/null and b/assets/tasks/16_3.idx differ diff --git a/assets/tasks/16_4.idx b/assets/tasks/16_4.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/16_5.idx b/assets/tasks/16_5.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/16_6.idx b/assets/tasks/16_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/16_7.bin b/assets/tasks/16_7.bin index 4e18194a..1d2d1696 100644 Binary files a/assets/tasks/16_7.bin and b/assets/tasks/16_7.bin differ diff --git a/assets/tasks/16_7.idx b/assets/tasks/16_7.idx new file mode 100644 index 00000000..0bb5f69f Binary files /dev/null and b/assets/tasks/16_7.idx differ diff --git a/assets/tasks/16_8.bin b/assets/tasks/16_8.bin index 1c6c9af6..c8dad4bc 100644 Binary files a/assets/tasks/16_8.bin and b/assets/tasks/16_8.bin differ diff --git a/assets/tasks/16_8.idx b/assets/tasks/16_8.idx new file mode 100644 index 00000000..393aa4fa Binary files /dev/null and b/assets/tasks/16_8.idx differ diff --git a/assets/tasks/16_9.idx b/assets/tasks/16_9.idx new file mode 100644 index 00000000..badac594 Binary files /dev/null and b/assets/tasks/16_9.idx differ diff --git a/assets/tasks/17_0.bin b/assets/tasks/17_0.bin index 7e4cda4a..84d0297e 100644 Binary files a/assets/tasks/17_0.bin and b/assets/tasks/17_0.bin differ diff --git a/assets/tasks/17_0.idx b/assets/tasks/17_0.idx new file mode 100644 index 00000000..7e55bd9a Binary files /dev/null and b/assets/tasks/17_0.idx differ diff --git a/assets/tasks/17_1.bin b/assets/tasks/17_1.bin index 7736319e..8dd59ae0 100644 Binary files a/assets/tasks/17_1.bin and b/assets/tasks/17_1.bin differ diff --git a/assets/tasks/17_1.idx b/assets/tasks/17_1.idx new file mode 100644 index 00000000..0a4d161e Binary files /dev/null and b/assets/tasks/17_1.idx differ diff --git a/assets/tasks/17_2.bin b/assets/tasks/17_2.bin index 8883b4ba..37536cc2 100644 Binary files a/assets/tasks/17_2.bin and b/assets/tasks/17_2.bin differ diff --git a/assets/tasks/17_2.idx b/assets/tasks/17_2.idx new file mode 100644 index 00000000..11df6147 Binary files /dev/null and b/assets/tasks/17_2.idx differ diff --git a/assets/tasks/17_3.bin b/assets/tasks/17_3.bin index fbc56648..270f136f 100644 Binary files a/assets/tasks/17_3.bin and b/assets/tasks/17_3.bin differ diff --git a/assets/tasks/17_3.idx b/assets/tasks/17_3.idx new file mode 100644 index 00000000..756cfa66 Binary files /dev/null and b/assets/tasks/17_3.idx differ diff --git a/assets/tasks/17_4.idx b/assets/tasks/17_4.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/17_5.idx b/assets/tasks/17_5.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/17_6.idx b/assets/tasks/17_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/17_7.bin b/assets/tasks/17_7.bin index c4a187ae..f06c7ebe 100644 Binary files a/assets/tasks/17_7.bin and b/assets/tasks/17_7.bin differ diff --git a/assets/tasks/17_7.idx b/assets/tasks/17_7.idx new file mode 100644 index 00000000..ef2742e5 Binary files /dev/null and b/assets/tasks/17_7.idx differ diff --git a/assets/tasks/17_8.bin b/assets/tasks/17_8.bin index 37c1f270..119baa25 100644 Binary files a/assets/tasks/17_8.bin and b/assets/tasks/17_8.bin differ diff --git a/assets/tasks/17_8.idx b/assets/tasks/17_8.idx new file mode 100644 index 00000000..bbfc239f Binary files /dev/null and b/assets/tasks/17_8.idx differ diff --git a/assets/tasks/17_9.bin b/assets/tasks/17_9.bin index 5c22464b..a2b2c5ca 100644 Binary files a/assets/tasks/17_9.bin and b/assets/tasks/17_9.bin differ diff --git a/assets/tasks/17_9.idx b/assets/tasks/17_9.idx new file mode 100644 index 00000000..1fa62e98 Binary files /dev/null and b/assets/tasks/17_9.idx differ diff --git a/assets/tasks/18_0.bin b/assets/tasks/18_0.bin index d48200a0..9909cf0a 100644 Binary files a/assets/tasks/18_0.bin and b/assets/tasks/18_0.bin differ diff --git a/assets/tasks/18_0.idx b/assets/tasks/18_0.idx new file mode 100644 index 00000000..2c0ec565 Binary files /dev/null and b/assets/tasks/18_0.idx differ diff --git a/assets/tasks/18_1.bin b/assets/tasks/18_1.bin index 62cac59e..58217400 100644 Binary files a/assets/tasks/18_1.bin and b/assets/tasks/18_1.bin differ diff --git a/assets/tasks/18_1.idx b/assets/tasks/18_1.idx new file mode 100644 index 00000000..2008588c Binary files /dev/null and b/assets/tasks/18_1.idx differ diff --git a/assets/tasks/18_2.bin b/assets/tasks/18_2.bin index b5480622..17519407 100644 Binary files a/assets/tasks/18_2.bin and b/assets/tasks/18_2.bin differ diff --git a/assets/tasks/18_2.idx b/assets/tasks/18_2.idx new file mode 100644 index 00000000..d935b1ce Binary files /dev/null and b/assets/tasks/18_2.idx differ diff --git a/assets/tasks/18_3.bin b/assets/tasks/18_3.bin index 9926a814..655128fc 100644 Binary files a/assets/tasks/18_3.bin and b/assets/tasks/18_3.bin differ diff --git a/assets/tasks/18_3.idx b/assets/tasks/18_3.idx new file mode 100644 index 00000000..bbf02976 Binary files /dev/null and b/assets/tasks/18_3.idx differ diff --git a/assets/tasks/18_4.idx b/assets/tasks/18_4.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/18_5.idx b/assets/tasks/18_5.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/18_6.idx b/assets/tasks/18_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/18_7.bin b/assets/tasks/18_7.bin index 88f24603..7ba615fb 100644 Binary files a/assets/tasks/18_7.bin and b/assets/tasks/18_7.bin differ diff --git a/assets/tasks/18_7.idx b/assets/tasks/18_7.idx new file mode 100644 index 00000000..13c90779 Binary files /dev/null and b/assets/tasks/18_7.idx differ diff --git a/assets/tasks/18_8.bin b/assets/tasks/18_8.bin index fa658024..19538251 100644 Binary files a/assets/tasks/18_8.bin and b/assets/tasks/18_8.bin differ diff --git a/assets/tasks/18_8.idx b/assets/tasks/18_8.idx new file mode 100644 index 00000000..3adf57bc Binary files /dev/null and b/assets/tasks/18_8.idx differ diff --git a/assets/tasks/18_9.bin b/assets/tasks/18_9.bin index ef99e1d8..2ea44526 100644 Binary files a/assets/tasks/18_9.bin and b/assets/tasks/18_9.bin differ diff --git a/assets/tasks/18_9.idx b/assets/tasks/18_9.idx new file mode 100644 index 00000000..ae96f8f8 Binary files /dev/null and b/assets/tasks/18_9.idx differ diff --git a/assets/tasks/19_0.bin b/assets/tasks/19_0.bin index 73595243..976fa0ec 100644 Binary files a/assets/tasks/19_0.bin and b/assets/tasks/19_0.bin differ diff --git a/assets/tasks/19_0.idx b/assets/tasks/19_0.idx new file mode 100644 index 00000000..3680fdb7 Binary files /dev/null and b/assets/tasks/19_0.idx differ diff --git a/assets/tasks/19_1.bin b/assets/tasks/19_1.bin index 5e16184b..0f167c0a 100644 Binary files a/assets/tasks/19_1.bin and b/assets/tasks/19_1.bin differ diff --git a/assets/tasks/19_1.idx b/assets/tasks/19_1.idx new file mode 100644 index 00000000..7b8fcbe3 Binary files /dev/null and b/assets/tasks/19_1.idx differ diff --git a/assets/tasks/19_2.bin b/assets/tasks/19_2.bin index 514c38de..5f22f64b 100644 Binary files a/assets/tasks/19_2.bin and b/assets/tasks/19_2.bin differ diff --git a/assets/tasks/19_2.idx b/assets/tasks/19_2.idx new file mode 100644 index 00000000..5d6af4e3 Binary files /dev/null and b/assets/tasks/19_2.idx differ diff --git a/assets/tasks/19_3.bin b/assets/tasks/19_3.bin index 5a626c80..61719b1d 100644 Binary files a/assets/tasks/19_3.bin and b/assets/tasks/19_3.bin differ diff --git a/assets/tasks/19_3.idx b/assets/tasks/19_3.idx new file mode 100644 index 00000000..735f9d40 Binary files /dev/null and b/assets/tasks/19_3.idx differ diff --git a/assets/tasks/19_4.idx b/assets/tasks/19_4.idx new file mode 100644 index 00000000..889c2c6a Binary files /dev/null and b/assets/tasks/19_4.idx differ diff --git a/assets/tasks/19_5.idx b/assets/tasks/19_5.idx new file mode 100644 index 00000000..8980b2e5 Binary files /dev/null and b/assets/tasks/19_5.idx differ diff --git a/assets/tasks/19_6.idx b/assets/tasks/19_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/19_7.bin b/assets/tasks/19_7.bin index b4caf1e8..f99a0c8f 100644 Binary files a/assets/tasks/19_7.bin and b/assets/tasks/19_7.bin differ diff --git a/assets/tasks/19_7.idx b/assets/tasks/19_7.idx new file mode 100644 index 00000000..682345b2 Binary files /dev/null and b/assets/tasks/19_7.idx differ diff --git a/assets/tasks/19_8.bin b/assets/tasks/19_8.bin index b10e7c22..e9b57b5d 100644 Binary files a/assets/tasks/19_8.bin and b/assets/tasks/19_8.bin differ diff --git a/assets/tasks/19_8.idx b/assets/tasks/19_8.idx new file mode 100644 index 00000000..9f1d1fb4 Binary files /dev/null and b/assets/tasks/19_8.idx differ diff --git a/assets/tasks/19_9.bin b/assets/tasks/19_9.bin index 4ae4ae08..e23139bf 100644 Binary files a/assets/tasks/19_9.bin and b/assets/tasks/19_9.bin differ diff --git a/assets/tasks/19_9.idx b/assets/tasks/19_9.idx new file mode 100644 index 00000000..b9ad9c83 Binary files /dev/null and b/assets/tasks/19_9.idx differ diff --git a/assets/tasks/20_0.bin b/assets/tasks/20_0.bin index d4024a73..23530b70 100644 Binary files a/assets/tasks/20_0.bin and b/assets/tasks/20_0.bin differ diff --git a/assets/tasks/20_0.idx b/assets/tasks/20_0.idx new file mode 100644 index 00000000..996504c0 Binary files /dev/null and b/assets/tasks/20_0.idx differ diff --git a/assets/tasks/20_1.bin b/assets/tasks/20_1.bin index 89e8f7bf..01c9aae5 100644 Binary files a/assets/tasks/20_1.bin and b/assets/tasks/20_1.bin differ diff --git a/assets/tasks/20_1.idx b/assets/tasks/20_1.idx new file mode 100644 index 00000000..128f2a3e Binary files /dev/null and b/assets/tasks/20_1.idx differ diff --git a/assets/tasks/20_2.bin b/assets/tasks/20_2.bin index fde94cc7..634116ba 100644 Binary files a/assets/tasks/20_2.bin and b/assets/tasks/20_2.bin differ diff --git a/assets/tasks/20_2.idx b/assets/tasks/20_2.idx new file mode 100644 index 00000000..61bf2ab1 Binary files /dev/null and b/assets/tasks/20_2.idx differ diff --git a/assets/tasks/20_3.bin b/assets/tasks/20_3.bin index 5ccd0e32..10359ded 100644 Binary files a/assets/tasks/20_3.bin and b/assets/tasks/20_3.bin differ diff --git a/assets/tasks/20_3.idx b/assets/tasks/20_3.idx new file mode 100644 index 00000000..4bebd7f7 Binary files /dev/null and b/assets/tasks/20_3.idx differ diff --git a/assets/tasks/20_4.bin b/assets/tasks/20_4.bin index 0bc55119..f8686c35 100644 Binary files a/assets/tasks/20_4.bin and b/assets/tasks/20_4.bin differ diff --git a/assets/tasks/20_4.idx b/assets/tasks/20_4.idx new file mode 100644 index 00000000..32315da9 Binary files /dev/null and b/assets/tasks/20_4.idx differ diff --git a/assets/tasks/20_5.bin b/assets/tasks/20_5.bin index cbec85a5..b48048b4 100644 Binary files a/assets/tasks/20_5.bin and b/assets/tasks/20_5.bin differ diff --git a/assets/tasks/20_5.idx b/assets/tasks/20_5.idx new file mode 100644 index 00000000..01dacdb0 Binary files /dev/null and b/assets/tasks/20_5.idx differ diff --git a/assets/tasks/20_6.idx b/assets/tasks/20_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/20_7.bin b/assets/tasks/20_7.bin index 806897be..22f3e988 100644 Binary files a/assets/tasks/20_7.bin and b/assets/tasks/20_7.bin differ diff --git a/assets/tasks/20_7.idx b/assets/tasks/20_7.idx new file mode 100644 index 00000000..5d10185b Binary files /dev/null and b/assets/tasks/20_7.idx differ diff --git a/assets/tasks/20_8.bin b/assets/tasks/20_8.bin index a1ebbf40..be7d4153 100644 Binary files a/assets/tasks/20_8.bin and b/assets/tasks/20_8.bin differ diff --git a/assets/tasks/20_8.idx b/assets/tasks/20_8.idx new file mode 100644 index 00000000..7fd3c520 Binary files /dev/null and b/assets/tasks/20_8.idx differ diff --git a/assets/tasks/20_9.bin b/assets/tasks/20_9.bin index 288959d0..7cb112b9 100644 Binary files a/assets/tasks/20_9.bin and b/assets/tasks/20_9.bin differ diff --git a/assets/tasks/20_9.idx b/assets/tasks/20_9.idx new file mode 100644 index 00000000..a5de2828 Binary files /dev/null and b/assets/tasks/20_9.idx differ diff --git a/assets/tasks/21_0.bin b/assets/tasks/21_0.bin index a99f494b..f89e0af8 100644 Binary files a/assets/tasks/21_0.bin and b/assets/tasks/21_0.bin differ diff --git a/assets/tasks/21_0.idx b/assets/tasks/21_0.idx new file mode 100644 index 00000000..2a115eae Binary files /dev/null and b/assets/tasks/21_0.idx differ diff --git a/assets/tasks/21_1.bin b/assets/tasks/21_1.bin index 2f596fc1..d80ac324 100644 Binary files a/assets/tasks/21_1.bin and b/assets/tasks/21_1.bin differ diff --git a/assets/tasks/21_1.idx b/assets/tasks/21_1.idx new file mode 100644 index 00000000..3a1badc0 Binary files /dev/null and b/assets/tasks/21_1.idx differ diff --git a/assets/tasks/21_2.bin b/assets/tasks/21_2.bin index 1ec2c2b7..2973795b 100644 Binary files a/assets/tasks/21_2.bin and b/assets/tasks/21_2.bin differ diff --git a/assets/tasks/21_2.idx b/assets/tasks/21_2.idx new file mode 100644 index 00000000..59c2de0b Binary files /dev/null and b/assets/tasks/21_2.idx differ diff --git a/assets/tasks/21_3.bin b/assets/tasks/21_3.bin index 66d2a402..2947a0e3 100644 Binary files a/assets/tasks/21_3.bin and b/assets/tasks/21_3.bin differ diff --git a/assets/tasks/21_3.idx b/assets/tasks/21_3.idx new file mode 100644 index 00000000..ad2c236a Binary files /dev/null and b/assets/tasks/21_3.idx differ diff --git a/assets/tasks/21_4.idx b/assets/tasks/21_4.idx new file mode 100644 index 00000000..ddeb6cb9 Binary files /dev/null and b/assets/tasks/21_4.idx differ diff --git a/assets/tasks/21_5.idx b/assets/tasks/21_5.idx new file mode 100644 index 00000000..0bcbfbb7 Binary files /dev/null and b/assets/tasks/21_5.idx differ diff --git a/assets/tasks/21_6.idx b/assets/tasks/21_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/21_7.bin b/assets/tasks/21_7.bin index b1b8d5ea..5d8edeb6 100644 Binary files a/assets/tasks/21_7.bin and b/assets/tasks/21_7.bin differ diff --git a/assets/tasks/21_7.idx b/assets/tasks/21_7.idx new file mode 100644 index 00000000..32523083 Binary files /dev/null and b/assets/tasks/21_7.idx differ diff --git a/assets/tasks/21_8.bin b/assets/tasks/21_8.bin index db604087..87400260 100644 Binary files a/assets/tasks/21_8.bin and b/assets/tasks/21_8.bin differ diff --git a/assets/tasks/21_8.idx b/assets/tasks/21_8.idx new file mode 100644 index 00000000..4e832cb9 Binary files /dev/null and b/assets/tasks/21_8.idx differ diff --git a/assets/tasks/21_9.bin b/assets/tasks/21_9.bin index e0e9e8fe..672c295c 100644 Binary files a/assets/tasks/21_9.bin and b/assets/tasks/21_9.bin differ diff --git a/assets/tasks/21_9.idx b/assets/tasks/21_9.idx new file mode 100644 index 00000000..2326179b Binary files /dev/null and b/assets/tasks/21_9.idx differ diff --git a/assets/tasks/22_0.bin b/assets/tasks/22_0.bin index daa60b7c..abade2d3 100644 Binary files a/assets/tasks/22_0.bin and b/assets/tasks/22_0.bin differ diff --git a/assets/tasks/22_0.idx b/assets/tasks/22_0.idx new file mode 100644 index 00000000..1a4d349b Binary files /dev/null and b/assets/tasks/22_0.idx differ diff --git a/assets/tasks/22_1.bin b/assets/tasks/22_1.bin index 912d74e5..b453d585 100644 Binary files a/assets/tasks/22_1.bin and b/assets/tasks/22_1.bin differ diff --git a/assets/tasks/22_1.idx b/assets/tasks/22_1.idx new file mode 100644 index 00000000..c667880a Binary files /dev/null and b/assets/tasks/22_1.idx differ diff --git a/assets/tasks/22_2.bin b/assets/tasks/22_2.bin index b6b0ca5a..e6fa89a3 100644 Binary files a/assets/tasks/22_2.bin and b/assets/tasks/22_2.bin differ diff --git a/assets/tasks/22_2.idx b/assets/tasks/22_2.idx new file mode 100644 index 00000000..24276461 Binary files /dev/null and b/assets/tasks/22_2.idx differ diff --git a/assets/tasks/22_3.bin b/assets/tasks/22_3.bin index 737b24f6..bbb12e1d 100644 Binary files a/assets/tasks/22_3.bin and b/assets/tasks/22_3.bin differ diff --git a/assets/tasks/22_3.idx b/assets/tasks/22_3.idx new file mode 100644 index 00000000..995585d9 Binary files /dev/null and b/assets/tasks/22_3.idx differ diff --git a/assets/tasks/22_4.bin b/assets/tasks/22_4.bin index 31bc75f1..26201e4b 100644 Binary files a/assets/tasks/22_4.bin and b/assets/tasks/22_4.bin differ diff --git a/assets/tasks/22_4.idx b/assets/tasks/22_4.idx new file mode 100644 index 00000000..6022eac0 Binary files /dev/null and b/assets/tasks/22_4.idx differ diff --git a/assets/tasks/22_5.bin b/assets/tasks/22_5.bin index d5a34281..0ada450a 100644 Binary files a/assets/tasks/22_5.bin and b/assets/tasks/22_5.bin differ diff --git a/assets/tasks/22_5.idx b/assets/tasks/22_5.idx new file mode 100644 index 00000000..90c13589 Binary files /dev/null and b/assets/tasks/22_5.idx differ diff --git a/assets/tasks/22_6.idx b/assets/tasks/22_6.idx new file mode 100644 index 00000000..100b962a --- /dev/null +++ b/assets/tasks/22_6.idx @@ -0,0 +1 @@ +3(.q6Y@B-6g@mk \ No newline at end of file diff --git a/assets/tasks/22_7.bin b/assets/tasks/22_7.bin index 69616d08..0952d26b 100644 Binary files a/assets/tasks/22_7.bin and b/assets/tasks/22_7.bin differ diff --git a/assets/tasks/22_7.idx b/assets/tasks/22_7.idx new file mode 100644 index 00000000..3bbc5f01 Binary files /dev/null and b/assets/tasks/22_7.idx differ diff --git a/assets/tasks/22_8.bin b/assets/tasks/22_8.bin index 97444b98..1db7b280 100644 Binary files a/assets/tasks/22_8.bin and b/assets/tasks/22_8.bin differ diff --git a/assets/tasks/22_8.idx b/assets/tasks/22_8.idx new file mode 100644 index 00000000..5f17463b Binary files /dev/null and b/assets/tasks/22_8.idx differ diff --git a/assets/tasks/22_9.bin b/assets/tasks/22_9.bin index 04d56a80..09effe96 100644 Binary files a/assets/tasks/22_9.bin and b/assets/tasks/22_9.bin differ diff --git a/assets/tasks/22_9.idx b/assets/tasks/22_9.idx new file mode 100644 index 00000000..99809b5d Binary files /dev/null and b/assets/tasks/22_9.idx differ diff --git a/assets/tasks/23_0.bin b/assets/tasks/23_0.bin index 00d36ba9..dde17286 100644 Binary files a/assets/tasks/23_0.bin and b/assets/tasks/23_0.bin differ diff --git a/assets/tasks/23_0.idx b/assets/tasks/23_0.idx new file mode 100644 index 00000000..0370242f Binary files /dev/null and b/assets/tasks/23_0.idx differ diff --git a/assets/tasks/23_1.bin b/assets/tasks/23_1.bin index 1f2396ed..18c04df3 100644 Binary files a/assets/tasks/23_1.bin and b/assets/tasks/23_1.bin differ diff --git a/assets/tasks/23_1.idx b/assets/tasks/23_1.idx new file mode 100644 index 00000000..517790cd Binary files /dev/null and b/assets/tasks/23_1.idx differ diff --git a/assets/tasks/23_2.bin b/assets/tasks/23_2.bin index ffb958d5..3b29b917 100644 Binary files a/assets/tasks/23_2.bin and b/assets/tasks/23_2.bin differ diff --git a/assets/tasks/23_2.idx b/assets/tasks/23_2.idx new file mode 100644 index 00000000..696b4bae Binary files /dev/null and b/assets/tasks/23_2.idx differ diff --git a/assets/tasks/23_3.bin b/assets/tasks/23_3.bin index 35c2196f..aceeed16 100644 Binary files a/assets/tasks/23_3.bin and b/assets/tasks/23_3.bin differ diff --git a/assets/tasks/23_3.idx b/assets/tasks/23_3.idx new file mode 100644 index 00000000..252008a8 Binary files /dev/null and b/assets/tasks/23_3.idx differ diff --git a/assets/tasks/23_4.bin b/assets/tasks/23_4.bin index 67e96591..2eef8923 100644 Binary files a/assets/tasks/23_4.bin and b/assets/tasks/23_4.bin differ diff --git a/assets/tasks/23_4.idx b/assets/tasks/23_4.idx new file mode 100644 index 00000000..df2ddf5b Binary files /dev/null and b/assets/tasks/23_4.idx differ diff --git a/assets/tasks/23_5.idx b/assets/tasks/23_5.idx new file mode 100644 index 00000000..b2e2df78 Binary files /dev/null and b/assets/tasks/23_5.idx differ diff --git a/assets/tasks/23_6.idx b/assets/tasks/23_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/23_7.bin b/assets/tasks/23_7.bin index ace67bb4..2374a657 100644 Binary files a/assets/tasks/23_7.bin and b/assets/tasks/23_7.bin differ diff --git a/assets/tasks/23_7.idx b/assets/tasks/23_7.idx new file mode 100644 index 00000000..0cca2b3b Binary files /dev/null and b/assets/tasks/23_7.idx differ diff --git a/assets/tasks/23_8.bin b/assets/tasks/23_8.bin index 2f6da422..04f7effa 100644 Binary files a/assets/tasks/23_8.bin and b/assets/tasks/23_8.bin differ diff --git a/assets/tasks/23_8.idx b/assets/tasks/23_8.idx new file mode 100644 index 00000000..9ae183bf Binary files /dev/null and b/assets/tasks/23_8.idx differ diff --git a/assets/tasks/23_9.bin b/assets/tasks/23_9.bin index 042efc96..18d98013 100644 Binary files a/assets/tasks/23_9.bin and b/assets/tasks/23_9.bin differ diff --git a/assets/tasks/23_9.idx b/assets/tasks/23_9.idx new file mode 100644 index 00000000..18186f18 Binary files /dev/null and b/assets/tasks/23_9.idx differ diff --git a/assets/tasks/24_0.bin b/assets/tasks/24_0.bin index cee6c7b9..dfd1e4fa 100644 Binary files a/assets/tasks/24_0.bin and b/assets/tasks/24_0.bin differ diff --git a/assets/tasks/24_0.idx b/assets/tasks/24_0.idx new file mode 100644 index 00000000..8baf0711 Binary files /dev/null and b/assets/tasks/24_0.idx differ diff --git a/assets/tasks/24_1.bin b/assets/tasks/24_1.bin index 1d7993a7..74f01a82 100644 Binary files a/assets/tasks/24_1.bin and b/assets/tasks/24_1.bin differ diff --git a/assets/tasks/24_1.idx b/assets/tasks/24_1.idx new file mode 100644 index 00000000..aaa26025 Binary files /dev/null and b/assets/tasks/24_1.idx differ diff --git a/assets/tasks/24_2.bin b/assets/tasks/24_2.bin index c94b9aff..68272cf8 100644 Binary files a/assets/tasks/24_2.bin and b/assets/tasks/24_2.bin differ diff --git a/assets/tasks/24_2.idx b/assets/tasks/24_2.idx new file mode 100644 index 00000000..4b03ff71 Binary files /dev/null and b/assets/tasks/24_2.idx differ diff --git a/assets/tasks/24_3.bin b/assets/tasks/24_3.bin index 311b57c6..20249352 100644 Binary files a/assets/tasks/24_3.bin and b/assets/tasks/24_3.bin differ diff --git a/assets/tasks/24_3.idx b/assets/tasks/24_3.idx new file mode 100644 index 00000000..7fc5498f Binary files /dev/null and b/assets/tasks/24_3.idx differ diff --git a/assets/tasks/24_4.bin b/assets/tasks/24_4.bin index 95c9331c..bb3da246 100644 Binary files a/assets/tasks/24_4.bin and b/assets/tasks/24_4.bin differ diff --git a/assets/tasks/24_4.idx b/assets/tasks/24_4.idx new file mode 100644 index 00000000..7634d33a Binary files /dev/null and b/assets/tasks/24_4.idx differ diff --git a/assets/tasks/24_5.bin b/assets/tasks/24_5.bin index efc970b7..0eb9e57b 100644 Binary files a/assets/tasks/24_5.bin and b/assets/tasks/24_5.bin differ diff --git a/assets/tasks/24_5.idx b/assets/tasks/24_5.idx new file mode 100644 index 00000000..2e5fee79 Binary files /dev/null and b/assets/tasks/24_5.idx differ diff --git a/assets/tasks/24_6.idx b/assets/tasks/24_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/24_7.bin b/assets/tasks/24_7.bin index bcb47e40..a2d5f644 100644 Binary files a/assets/tasks/24_7.bin and b/assets/tasks/24_7.bin differ diff --git a/assets/tasks/24_7.idx b/assets/tasks/24_7.idx new file mode 100644 index 00000000..75867cae Binary files /dev/null and b/assets/tasks/24_7.idx differ diff --git a/assets/tasks/24_8.bin b/assets/tasks/24_8.bin index bb692b50..6f0e3c1e 100644 Binary files a/assets/tasks/24_8.bin and b/assets/tasks/24_8.bin differ diff --git a/assets/tasks/24_8.idx b/assets/tasks/24_8.idx new file mode 100644 index 00000000..ed539254 Binary files /dev/null and b/assets/tasks/24_8.idx differ diff --git a/assets/tasks/24_9.bin b/assets/tasks/24_9.bin index 41481427..d7123b6b 100644 Binary files a/assets/tasks/24_9.bin and b/assets/tasks/24_9.bin differ diff --git a/assets/tasks/24_9.idx b/assets/tasks/24_9.idx new file mode 100644 index 00000000..c0ff2e27 Binary files /dev/null and b/assets/tasks/24_9.idx differ diff --git a/assets/tasks/25_0.bin b/assets/tasks/25_0.bin index 7910de9e..8cd4803f 100644 Binary files a/assets/tasks/25_0.bin and b/assets/tasks/25_0.bin differ diff --git a/assets/tasks/25_0.idx b/assets/tasks/25_0.idx new file mode 100644 index 00000000..06929a78 Binary files /dev/null and b/assets/tasks/25_0.idx differ diff --git a/assets/tasks/25_1.bin b/assets/tasks/25_1.bin index 1ffdc72a..7cfa1f63 100644 Binary files a/assets/tasks/25_1.bin and b/assets/tasks/25_1.bin differ diff --git a/assets/tasks/25_1.idx b/assets/tasks/25_1.idx new file mode 100644 index 00000000..cec25709 Binary files /dev/null and b/assets/tasks/25_1.idx differ diff --git a/assets/tasks/25_2.bin b/assets/tasks/25_2.bin index 0b003c42..cbe69c42 100644 Binary files a/assets/tasks/25_2.bin and b/assets/tasks/25_2.bin differ diff --git a/assets/tasks/25_2.idx b/assets/tasks/25_2.idx new file mode 100644 index 00000000..63356694 Binary files /dev/null and b/assets/tasks/25_2.idx differ diff --git a/assets/tasks/25_3.bin b/assets/tasks/25_3.bin index 7a837d03..50dd6563 100644 Binary files a/assets/tasks/25_3.bin and b/assets/tasks/25_3.bin differ diff --git a/assets/tasks/25_3.idx b/assets/tasks/25_3.idx new file mode 100644 index 00000000..90e79a01 Binary files /dev/null and b/assets/tasks/25_3.idx differ diff --git a/assets/tasks/25_4.bin b/assets/tasks/25_4.bin index c8040ffa..8d806d41 100644 Binary files a/assets/tasks/25_4.bin and b/assets/tasks/25_4.bin differ diff --git a/assets/tasks/25_4.idx b/assets/tasks/25_4.idx new file mode 100644 index 00000000..a1185709 Binary files /dev/null and b/assets/tasks/25_4.idx differ diff --git a/assets/tasks/25_5.bin b/assets/tasks/25_5.bin index 2c32ec41..801f4798 100644 Binary files a/assets/tasks/25_5.bin and b/assets/tasks/25_5.bin differ diff --git a/assets/tasks/25_5.idx b/assets/tasks/25_5.idx new file mode 100644 index 00000000..b89d7106 Binary files /dev/null and b/assets/tasks/25_5.idx differ diff --git a/assets/tasks/25_6.idx b/assets/tasks/25_6.idx new file mode 100644 index 00000000..4582bce9 --- /dev/null +++ b/assets/tasks/25_6.idx @@ -0,0 +1 @@ +{ߺ;u?Y̽Ң^6oM闱{{ߺ;|7YҢZ2}Ms{ߺ:#h'Y҂#Z6Uk7 \ No newline at end of file diff --git a/assets/tasks/25_7.bin b/assets/tasks/25_7.bin index 7174b554..35fbf270 100644 Binary files a/assets/tasks/25_7.bin and b/assets/tasks/25_7.bin differ diff --git a/assets/tasks/25_7.idx b/assets/tasks/25_7.idx new file mode 100644 index 00000000..00eb8d53 Binary files /dev/null and b/assets/tasks/25_7.idx differ diff --git a/assets/tasks/25_8.bin b/assets/tasks/25_8.bin index 0ca8a989..c1ef873d 100644 Binary files a/assets/tasks/25_8.bin and b/assets/tasks/25_8.bin differ diff --git a/assets/tasks/25_8.idx b/assets/tasks/25_8.idx new file mode 100644 index 00000000..b57acf41 Binary files /dev/null and b/assets/tasks/25_8.idx differ diff --git a/assets/tasks/25_9.bin b/assets/tasks/25_9.bin index 507db863..d269b76f 100644 Binary files a/assets/tasks/25_9.bin and b/assets/tasks/25_9.bin differ diff --git a/assets/tasks/25_9.idx b/assets/tasks/25_9.idx new file mode 100644 index 00000000..1e2f5b66 Binary files /dev/null and b/assets/tasks/25_9.idx differ diff --git a/assets/tasks/26_0.bin b/assets/tasks/26_0.bin index da4dbf0e..a307cb3d 100644 Binary files a/assets/tasks/26_0.bin and b/assets/tasks/26_0.bin differ diff --git a/assets/tasks/26_0.idx b/assets/tasks/26_0.idx new file mode 100644 index 00000000..ca1eb71e Binary files /dev/null and b/assets/tasks/26_0.idx differ diff --git a/assets/tasks/26_1.bin b/assets/tasks/26_1.bin index d2155573..2e3ef1a0 100644 Binary files a/assets/tasks/26_1.bin and b/assets/tasks/26_1.bin differ diff --git a/assets/tasks/26_1.idx b/assets/tasks/26_1.idx new file mode 100644 index 00000000..82e930f2 Binary files /dev/null and b/assets/tasks/26_1.idx differ diff --git a/assets/tasks/26_2.bin b/assets/tasks/26_2.bin index 885e6cec..f9115c68 100644 Binary files a/assets/tasks/26_2.bin and b/assets/tasks/26_2.bin differ diff --git a/assets/tasks/26_2.idx b/assets/tasks/26_2.idx new file mode 100644 index 00000000..fbb356ad Binary files /dev/null and b/assets/tasks/26_2.idx differ diff --git a/assets/tasks/26_3.bin b/assets/tasks/26_3.bin index 97d080f9..8e694295 100644 Binary files a/assets/tasks/26_3.bin and b/assets/tasks/26_3.bin differ diff --git a/assets/tasks/26_3.idx b/assets/tasks/26_3.idx new file mode 100644 index 00000000..0c1f5a3d Binary files /dev/null and b/assets/tasks/26_3.idx differ diff --git a/assets/tasks/26_4.bin b/assets/tasks/26_4.bin index 2d0f5569..883cfeb2 100644 Binary files a/assets/tasks/26_4.bin and b/assets/tasks/26_4.bin differ diff --git a/assets/tasks/26_4.idx b/assets/tasks/26_4.idx new file mode 100644 index 00000000..09dfe64d Binary files /dev/null and b/assets/tasks/26_4.idx differ diff --git a/assets/tasks/26_5.bin b/assets/tasks/26_5.bin index 3d7c4c69..27dc04db 100644 Binary files a/assets/tasks/26_5.bin and b/assets/tasks/26_5.bin differ diff --git a/assets/tasks/26_5.idx b/assets/tasks/26_5.idx new file mode 100644 index 00000000..5fb9ae47 Binary files /dev/null and b/assets/tasks/26_5.idx 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_6.idx b/assets/tasks/26_6.idx new file mode 100644 index 00000000..a88b9d40 --- /dev/null +++ b/assets/tasks/26_6.idx @@ -0,0 +1 @@ +{ߺ;}?YҢZ6}闳{yct7XҢZ2ɭ]Sߺ;}?YҢZ6]뗳{ \ No newline at end of file diff --git a/assets/tasks/26_7.bin b/assets/tasks/26_7.bin index 3ba88b60..aae442f9 100644 Binary files a/assets/tasks/26_7.bin and b/assets/tasks/26_7.bin differ diff --git a/assets/tasks/26_7.idx b/assets/tasks/26_7.idx new file mode 100644 index 00000000..c865406a Binary files /dev/null and b/assets/tasks/26_7.idx differ diff --git a/assets/tasks/26_8.bin b/assets/tasks/26_8.bin index 89040ef7..4b0aa835 100644 Binary files a/assets/tasks/26_8.bin and b/assets/tasks/26_8.bin differ diff --git a/assets/tasks/26_8.idx b/assets/tasks/26_8.idx new file mode 100644 index 00000000..2af0fae8 Binary files /dev/null and b/assets/tasks/26_8.idx differ diff --git a/assets/tasks/26_9.bin b/assets/tasks/26_9.bin index f891adea..03af1ac5 100644 Binary files a/assets/tasks/26_9.bin and b/assets/tasks/26_9.bin differ diff --git a/assets/tasks/26_9.idx b/assets/tasks/26_9.idx new file mode 100644 index 00000000..4928af6e Binary files /dev/null and b/assets/tasks/26_9.idx differ diff --git a/assets/tasks/27_0.bin b/assets/tasks/27_0.bin index f313df8f..017fdd99 100644 Binary files a/assets/tasks/27_0.bin and b/assets/tasks/27_0.bin differ diff --git a/assets/tasks/27_0.idx b/assets/tasks/27_0.idx new file mode 100644 index 00000000..b176ffea Binary files /dev/null and b/assets/tasks/27_0.idx differ diff --git a/assets/tasks/27_1.bin b/assets/tasks/27_1.bin index 870137c2..7f461b7c 100644 Binary files a/assets/tasks/27_1.bin and b/assets/tasks/27_1.bin differ diff --git a/assets/tasks/27_1.idx b/assets/tasks/27_1.idx new file mode 100644 index 00000000..42458e4f Binary files /dev/null and b/assets/tasks/27_1.idx differ diff --git a/assets/tasks/27_2.bin b/assets/tasks/27_2.bin index dce5a1a8..e3a69d0f 100644 Binary files a/assets/tasks/27_2.bin and b/assets/tasks/27_2.bin differ diff --git a/assets/tasks/27_2.idx b/assets/tasks/27_2.idx new file mode 100644 index 00000000..050a4ef2 Binary files /dev/null and b/assets/tasks/27_2.idx differ diff --git a/assets/tasks/27_3.bin b/assets/tasks/27_3.bin index 1778b114..e55985db 100644 Binary files a/assets/tasks/27_3.bin and b/assets/tasks/27_3.bin differ diff --git a/assets/tasks/27_3.idx b/assets/tasks/27_3.idx new file mode 100644 index 00000000..acfb160e Binary files /dev/null and b/assets/tasks/27_3.idx differ diff --git a/assets/tasks/27_4.bin b/assets/tasks/27_4.bin index fc6ab833..dbd554b1 100644 Binary files a/assets/tasks/27_4.bin and b/assets/tasks/27_4.bin differ diff --git a/assets/tasks/27_4.idx b/assets/tasks/27_4.idx new file mode 100644 index 00000000..f6e79444 Binary files /dev/null and b/assets/tasks/27_4.idx differ diff --git a/assets/tasks/27_5.bin b/assets/tasks/27_5.bin index 8f37cbb0..8893da66 100644 Binary files a/assets/tasks/27_5.bin and b/assets/tasks/27_5.bin differ diff --git a/assets/tasks/27_5.idx b/assets/tasks/27_5.idx new file mode 100644 index 00000000..c04872b6 Binary files /dev/null and b/assets/tasks/27_5.idx differ diff --git a/assets/tasks/27_6.bin b/assets/tasks/27_6.bin index b69dc7cf..9c35a485 100644 Binary files a/assets/tasks/27_6.bin and b/assets/tasks/27_6.bin differ diff --git a/assets/tasks/27_6.idx b/assets/tasks/27_6.idx new file mode 100644 index 00000000..59434a71 --- /dev/null +++ b/assets/tasks/27_6.idx @@ -0,0 +1,2 @@ +{߲;s}7YҢZ>U3s{ߺ;|?Y̼ҢZ6]闓{ߺ;?YҢZ6}럳{?[ӣ^>}{ߺ:#u3Y҂Z2oMs{ +#|?Y"B>]띡:2_:(>l3I Z,"mIiD6ߺ+l?[Ӣ^6]뗣{_*c|7Y"J6}럱ZV{ߺ;t3Y̽ҢZ6]{ߺ;|?YҢZ2]뗳s{ߺ;|t7Y҂Z2}]S{ߺ:m7YҢZ͗2Ur;ߺ;~|7Y҂Z2}]뗓S{;3|?Y҂J6]{ \ No newline at end of file diff --git a/assets/tasks/27_7.bin b/assets/tasks/27_7.bin index b7e9eab3..a616b253 100644 Binary files a/assets/tasks/27_7.bin and b/assets/tasks/27_7.bin differ diff --git a/assets/tasks/27_7.idx b/assets/tasks/27_7.idx new file mode 100644 index 00000000..37cce892 Binary files /dev/null and b/assets/tasks/27_7.idx differ diff --git a/assets/tasks/27_8.bin b/assets/tasks/27_8.bin index b0a8f831..b131d649 100644 Binary files a/assets/tasks/27_8.bin and b/assets/tasks/27_8.bin differ diff --git a/assets/tasks/27_8.idx b/assets/tasks/27_8.idx new file mode 100644 index 00000000..ed252438 Binary files /dev/null and b/assets/tasks/27_8.idx differ diff --git a/assets/tasks/27_9.bin b/assets/tasks/27_9.bin index 57a3277f..4e411c22 100644 Binary files a/assets/tasks/27_9.bin and b/assets/tasks/27_9.bin differ diff --git a/assets/tasks/27_9.idx b/assets/tasks/27_9.idx new file mode 100644 index 00000000..1b35cc16 Binary files /dev/null and b/assets/tasks/27_9.idx differ diff --git a/assets/tasks/28_0.bin b/assets/tasks/28_0.bin index b3817a57..eaa91d44 100644 Binary files a/assets/tasks/28_0.bin and b/assets/tasks/28_0.bin differ diff --git a/assets/tasks/28_0.idx b/assets/tasks/28_0.idx new file mode 100644 index 00000000..59f10988 Binary files /dev/null and b/assets/tasks/28_0.idx differ diff --git a/assets/tasks/28_1.bin b/assets/tasks/28_1.bin index 5cbd0207..e9af5264 100644 Binary files a/assets/tasks/28_1.bin and b/assets/tasks/28_1.bin differ diff --git a/assets/tasks/28_1.idx b/assets/tasks/28_1.idx new file mode 100644 index 00000000..dbc418b0 Binary files /dev/null and b/assets/tasks/28_1.idx differ diff --git a/assets/tasks/28_2.bin b/assets/tasks/28_2.bin index 16ed7106..e204c795 100644 Binary files a/assets/tasks/28_2.bin and b/assets/tasks/28_2.bin differ diff --git a/assets/tasks/28_2.idx b/assets/tasks/28_2.idx new file mode 100644 index 00000000..3ea0f4f7 Binary files /dev/null and b/assets/tasks/28_2.idx differ diff --git a/assets/tasks/28_3.bin b/assets/tasks/28_3.bin index 87b84dfa..082be099 100644 Binary files a/assets/tasks/28_3.bin and b/assets/tasks/28_3.bin differ diff --git a/assets/tasks/28_3.idx b/assets/tasks/28_3.idx new file mode 100644 index 00000000..7dc7110c Binary files /dev/null and b/assets/tasks/28_3.idx differ diff --git a/assets/tasks/28_4.bin b/assets/tasks/28_4.bin index cda7dda0..ab5ca196 100644 Binary files a/assets/tasks/28_4.bin and b/assets/tasks/28_4.bin differ diff --git a/assets/tasks/28_4.idx b/assets/tasks/28_4.idx new file mode 100644 index 00000000..d66f57fa Binary files /dev/null and b/assets/tasks/28_4.idx differ diff --git a/assets/tasks/28_5.bin b/assets/tasks/28_5.bin index b854ae95..20ebad2d 100644 Binary files a/assets/tasks/28_5.bin and b/assets/tasks/28_5.bin differ diff --git a/assets/tasks/28_5.idx b/assets/tasks/28_5.idx new file mode 100644 index 00000000..6ca922d2 Binary files /dev/null and b/assets/tasks/28_5.idx differ diff --git a/assets/tasks/28_6.bin b/assets/tasks/28_6.bin index d743fede..764411fd 100644 Binary files a/assets/tasks/28_6.bin and b/assets/tasks/28_6.bin differ diff --git a/assets/tasks/28_6.idx b/assets/tasks/28_6.idx new file mode 100644 index 00000000..9313b60f Binary files /dev/null and b/assets/tasks/28_6.idx differ diff --git a/assets/tasks/28_7.bin b/assets/tasks/28_7.bin index 23241da7..44821249 100644 Binary files a/assets/tasks/28_7.bin and b/assets/tasks/28_7.bin differ diff --git a/assets/tasks/28_7.idx b/assets/tasks/28_7.idx new file mode 100644 index 00000000..a316f4a5 Binary files /dev/null and b/assets/tasks/28_7.idx differ diff --git a/assets/tasks/28_8.bin b/assets/tasks/28_8.bin index 62b71e55..2c88d961 100644 Binary files a/assets/tasks/28_8.bin and b/assets/tasks/28_8.bin differ diff --git a/assets/tasks/28_8.idx b/assets/tasks/28_8.idx new file mode 100644 index 00000000..fb830d97 Binary files /dev/null and b/assets/tasks/28_8.idx differ diff --git a/assets/tasks/28_9.bin b/assets/tasks/28_9.bin index 49612774..729fb6d1 100644 Binary files a/assets/tasks/28_9.bin and b/assets/tasks/28_9.bin differ diff --git a/assets/tasks/28_9.idx b/assets/tasks/28_9.idx new file mode 100644 index 00000000..274055c4 Binary files /dev/null and b/assets/tasks/28_9.idx differ diff --git a/assets/tasks/29_0.bin b/assets/tasks/29_0.bin index 571f8e25..a4a3b272 100644 Binary files a/assets/tasks/29_0.bin and b/assets/tasks/29_0.bin differ diff --git a/assets/tasks/29_0.idx b/assets/tasks/29_0.idx new file mode 100644 index 00000000..c0a57202 Binary files /dev/null and b/assets/tasks/29_0.idx differ diff --git a/assets/tasks/29_1.bin b/assets/tasks/29_1.bin index 4adfe6ff..a8b2019c 100644 Binary files a/assets/tasks/29_1.bin and b/assets/tasks/29_1.bin differ diff --git a/assets/tasks/29_1.idx b/assets/tasks/29_1.idx new file mode 100644 index 00000000..378390e1 Binary files /dev/null and b/assets/tasks/29_1.idx 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_2.idx b/assets/tasks/29_2.idx new file mode 100644 index 00000000..7834e7d3 Binary files /dev/null and b/assets/tasks/29_2.idx differ diff --git a/assets/tasks/29_3.bin b/assets/tasks/29_3.bin index dd4254b0..60880dad 100644 Binary files a/assets/tasks/29_3.bin and b/assets/tasks/29_3.bin differ diff --git a/assets/tasks/29_3.idx b/assets/tasks/29_3.idx new file mode 100644 index 00000000..d0c7c2c2 Binary files /dev/null and b/assets/tasks/29_3.idx differ diff --git a/assets/tasks/29_4.bin b/assets/tasks/29_4.bin index 9e236cc4..bc25ea63 100644 Binary files a/assets/tasks/29_4.bin and b/assets/tasks/29_4.bin differ diff --git a/assets/tasks/29_4.idx b/assets/tasks/29_4.idx new file mode 100644 index 00000000..c113ea6a Binary files /dev/null and b/assets/tasks/29_4.idx differ diff --git a/assets/tasks/29_5.bin b/assets/tasks/29_5.bin index 5e9ff784..98e31970 100644 Binary files a/assets/tasks/29_5.bin and b/assets/tasks/29_5.bin differ diff --git a/assets/tasks/29_5.idx b/assets/tasks/29_5.idx new file mode 100644 index 00000000..e591b0fa Binary files /dev/null and b/assets/tasks/29_5.idx differ diff --git a/assets/tasks/29_6.bin b/assets/tasks/29_6.bin index 88e9db5c..6293de78 100644 Binary files a/assets/tasks/29_6.bin and b/assets/tasks/29_6.bin differ diff --git a/assets/tasks/29_6.idx b/assets/tasks/29_6.idx new file mode 100644 index 00000000..6927e0b4 Binary files /dev/null and b/assets/tasks/29_6.idx differ diff --git a/assets/tasks/29_7.bin b/assets/tasks/29_7.bin index 918bbe49..0d35db98 100644 Binary files a/assets/tasks/29_7.bin and b/assets/tasks/29_7.bin differ diff --git a/assets/tasks/29_7.idx b/assets/tasks/29_7.idx new file mode 100644 index 00000000..a772bb8e Binary files /dev/null and b/assets/tasks/29_7.idx differ diff --git a/assets/tasks/29_8.bin b/assets/tasks/29_8.bin index 8d981e0c..d40f4088 100644 Binary files a/assets/tasks/29_8.bin and b/assets/tasks/29_8.bin differ diff --git a/assets/tasks/29_8.idx b/assets/tasks/29_8.idx new file mode 100644 index 00000000..9a9d8f8e Binary files /dev/null and b/assets/tasks/29_8.idx differ diff --git a/assets/tasks/29_9.bin b/assets/tasks/29_9.bin index e0d52c8e..c9d28a8d 100644 Binary files a/assets/tasks/29_9.bin and b/assets/tasks/29_9.bin differ diff --git a/assets/tasks/29_9.idx b/assets/tasks/29_9.idx new file mode 100644 index 00000000..9a7b6748 Binary files /dev/null and b/assets/tasks/29_9.idx differ diff --git a/assets/tasks/30_0.bin b/assets/tasks/30_0.bin index 8844efd8..d89fb99b 100644 Binary files a/assets/tasks/30_0.bin and b/assets/tasks/30_0.bin differ diff --git a/assets/tasks/30_0.idx b/assets/tasks/30_0.idx new file mode 100644 index 00000000..1fca58e6 Binary files /dev/null and b/assets/tasks/30_0.idx differ diff --git a/assets/tasks/30_1.bin b/assets/tasks/30_1.bin index 43da297c..57a0e3e4 100644 Binary files a/assets/tasks/30_1.bin and b/assets/tasks/30_1.bin differ diff --git a/assets/tasks/30_1.idx b/assets/tasks/30_1.idx new file mode 100644 index 00000000..a3e11309 Binary files /dev/null and b/assets/tasks/30_1.idx differ diff --git a/assets/tasks/30_2.bin b/assets/tasks/30_2.bin index 3f172e5e..6e745839 100644 Binary files a/assets/tasks/30_2.bin and b/assets/tasks/30_2.bin differ diff --git a/assets/tasks/30_2.idx b/assets/tasks/30_2.idx new file mode 100644 index 00000000..b6bdd84b Binary files /dev/null and b/assets/tasks/30_2.idx differ diff --git a/assets/tasks/30_3.bin b/assets/tasks/30_3.bin index 2ec43311..a417fc52 100644 Binary files a/assets/tasks/30_3.bin and b/assets/tasks/30_3.bin differ diff --git a/assets/tasks/30_3.idx b/assets/tasks/30_3.idx new file mode 100644 index 00000000..bbbf990d Binary files /dev/null and b/assets/tasks/30_3.idx differ diff --git a/assets/tasks/30_4.bin b/assets/tasks/30_4.bin index 9fd35d70..883833fa 100644 Binary files a/assets/tasks/30_4.bin and b/assets/tasks/30_4.bin differ diff --git a/assets/tasks/30_4.idx b/assets/tasks/30_4.idx new file mode 100644 index 00000000..f9bc9ca1 Binary files /dev/null and b/assets/tasks/30_4.idx differ diff --git a/assets/tasks/30_5.bin b/assets/tasks/30_5.bin index 4d24b2bf..44292976 100644 Binary files a/assets/tasks/30_5.bin and b/assets/tasks/30_5.bin differ diff --git a/assets/tasks/30_5.idx b/assets/tasks/30_5.idx new file mode 100644 index 00000000..1d67c7f0 Binary files /dev/null and b/assets/tasks/30_5.idx differ diff --git a/assets/tasks/30_6.bin b/assets/tasks/30_6.bin index 795b40c0..967166a2 100644 Binary files a/assets/tasks/30_6.bin and b/assets/tasks/30_6.bin differ diff --git a/assets/tasks/30_6.idx b/assets/tasks/30_6.idx new file mode 100644 index 00000000..02747105 Binary files /dev/null and b/assets/tasks/30_6.idx differ diff --git a/assets/tasks/30_7.bin b/assets/tasks/30_7.bin index b14d41c9..fa45a73e 100644 Binary files a/assets/tasks/30_7.bin and b/assets/tasks/30_7.bin differ diff --git a/assets/tasks/30_7.idx b/assets/tasks/30_7.idx new file mode 100644 index 00000000..3aa4b931 Binary files /dev/null and b/assets/tasks/30_7.idx differ diff --git a/assets/tasks/30_8.bin b/assets/tasks/30_8.bin index ded705ee..743c0550 100644 Binary files a/assets/tasks/30_8.bin and b/assets/tasks/30_8.bin differ diff --git a/assets/tasks/30_8.idx b/assets/tasks/30_8.idx new file mode 100644 index 00000000..40d0bba5 Binary files /dev/null and b/assets/tasks/30_8.idx differ diff --git a/assets/tasks/30_9.bin b/assets/tasks/30_9.bin index 1bd0228b..c7ba067c 100644 Binary files a/assets/tasks/30_9.bin and b/assets/tasks/30_9.bin differ diff --git a/assets/tasks/30_9.idx b/assets/tasks/30_9.idx new file mode 100644 index 00000000..fa56849e Binary files /dev/null and b/assets/tasks/30_9.idx differ diff --git a/assets/tasks/31_0.bin b/assets/tasks/31_0.bin index 0f72993b..defcb8d1 100644 Binary files a/assets/tasks/31_0.bin and b/assets/tasks/31_0.bin differ diff --git a/assets/tasks/31_0.idx b/assets/tasks/31_0.idx new file mode 100644 index 00000000..2a3a5864 Binary files /dev/null and b/assets/tasks/31_0.idx differ diff --git a/assets/tasks/31_1.bin b/assets/tasks/31_1.bin index cd22bdb6..5795e409 100644 Binary files a/assets/tasks/31_1.bin and b/assets/tasks/31_1.bin differ diff --git a/assets/tasks/31_1.idx b/assets/tasks/31_1.idx new file mode 100644 index 00000000..57bfee41 Binary files /dev/null and b/assets/tasks/31_1.idx differ diff --git a/assets/tasks/31_2.bin b/assets/tasks/31_2.bin index 84f4ab5c..31363096 100644 Binary files a/assets/tasks/31_2.bin and b/assets/tasks/31_2.bin differ diff --git a/assets/tasks/31_2.idx b/assets/tasks/31_2.idx new file mode 100644 index 00000000..dc2ed03c Binary files /dev/null and b/assets/tasks/31_2.idx differ diff --git a/assets/tasks/31_3.bin b/assets/tasks/31_3.bin index 13064414..59725ab8 100644 Binary files a/assets/tasks/31_3.bin and b/assets/tasks/31_3.bin differ diff --git a/assets/tasks/31_3.idx b/assets/tasks/31_3.idx new file mode 100644 index 00000000..13c63d66 Binary files /dev/null and b/assets/tasks/31_3.idx differ diff --git a/assets/tasks/31_4.bin b/assets/tasks/31_4.bin index aca018f8..ebe9e2f0 100644 Binary files a/assets/tasks/31_4.bin and b/assets/tasks/31_4.bin differ diff --git a/assets/tasks/31_4.idx b/assets/tasks/31_4.idx new file mode 100644 index 00000000..cd0f8e72 Binary files /dev/null and b/assets/tasks/31_4.idx differ diff --git a/assets/tasks/31_5.bin b/assets/tasks/31_5.bin index c9045350..e5e3759f 100644 Binary files a/assets/tasks/31_5.bin and b/assets/tasks/31_5.bin differ diff --git a/assets/tasks/31_5.idx b/assets/tasks/31_5.idx new file mode 100644 index 00000000..03da9718 Binary files /dev/null and b/assets/tasks/31_5.idx differ diff --git a/assets/tasks/31_6.bin b/assets/tasks/31_6.bin index 64e9b758..f6abb7a3 100644 Binary files a/assets/tasks/31_6.bin and b/assets/tasks/31_6.bin differ diff --git a/assets/tasks/31_6.idx b/assets/tasks/31_6.idx new file mode 100644 index 00000000..2e5cfa02 Binary files /dev/null and b/assets/tasks/31_6.idx differ diff --git a/assets/tasks/31_7.bin b/assets/tasks/31_7.bin index 5a8e288f..10a94f2b 100644 Binary files a/assets/tasks/31_7.bin and b/assets/tasks/31_7.bin differ diff --git a/assets/tasks/31_7.idx b/assets/tasks/31_7.idx new file mode 100644 index 00000000..5278547c Binary files /dev/null and b/assets/tasks/31_7.idx differ diff --git a/assets/tasks/31_8.bin b/assets/tasks/31_8.bin index 583764cf..1522cccc 100644 Binary files a/assets/tasks/31_8.bin and b/assets/tasks/31_8.bin differ diff --git a/assets/tasks/31_8.idx b/assets/tasks/31_8.idx new file mode 100644 index 00000000..fa7eb02b Binary files /dev/null and b/assets/tasks/31_8.idx differ diff --git a/assets/tasks/31_9.bin b/assets/tasks/31_9.bin index 6dbfd7e9..51bf4774 100644 Binary files a/assets/tasks/31_9.bin and b/assets/tasks/31_9.bin differ diff --git a/assets/tasks/31_9.idx b/assets/tasks/31_9.idx new file mode 100644 index 00000000..edeb8a4c Binary files /dev/null and b/assets/tasks/31_9.idx differ diff --git a/assets/tasks/32_0.bin b/assets/tasks/32_0.bin index fe3c4169..a5b2a8bf 100644 Binary files a/assets/tasks/32_0.bin and b/assets/tasks/32_0.bin differ diff --git a/assets/tasks/32_0.idx b/assets/tasks/32_0.idx new file mode 100644 index 00000000..27bef1ca Binary files /dev/null and b/assets/tasks/32_0.idx differ diff --git a/assets/tasks/32_1.bin b/assets/tasks/32_1.bin index 402ef7a4..79dcf5ba 100644 Binary files a/assets/tasks/32_1.bin and b/assets/tasks/32_1.bin differ diff --git a/assets/tasks/32_1.idx b/assets/tasks/32_1.idx new file mode 100644 index 00000000..a5b03085 Binary files /dev/null and b/assets/tasks/32_1.idx differ diff --git a/assets/tasks/32_2.bin b/assets/tasks/32_2.bin index f9871ca6..9211fb95 100644 Binary files a/assets/tasks/32_2.bin and b/assets/tasks/32_2.bin differ diff --git a/assets/tasks/32_2.idx b/assets/tasks/32_2.idx new file mode 100644 index 00000000..cf3c3b64 Binary files /dev/null and b/assets/tasks/32_2.idx differ diff --git a/assets/tasks/32_3.bin b/assets/tasks/32_3.bin index a24b7303..46956f96 100644 Binary files a/assets/tasks/32_3.bin and b/assets/tasks/32_3.bin differ diff --git a/assets/tasks/32_3.idx b/assets/tasks/32_3.idx new file mode 100644 index 00000000..8eace643 Binary files /dev/null and b/assets/tasks/32_3.idx differ diff --git a/assets/tasks/32_4.bin b/assets/tasks/32_4.bin index 8e81f817..6343eb30 100644 Binary files a/assets/tasks/32_4.bin and b/assets/tasks/32_4.bin differ diff --git a/assets/tasks/32_4.idx b/assets/tasks/32_4.idx new file mode 100644 index 00000000..f59afbab Binary files /dev/null and b/assets/tasks/32_4.idx differ diff --git a/assets/tasks/32_5.bin b/assets/tasks/32_5.bin index 7b7bb5b5..45bce3b1 100644 Binary files a/assets/tasks/32_5.bin and b/assets/tasks/32_5.bin differ diff --git a/assets/tasks/32_5.idx b/assets/tasks/32_5.idx new file mode 100644 index 00000000..7ae7b256 Binary files /dev/null and b/assets/tasks/32_5.idx differ diff --git a/assets/tasks/32_6.bin b/assets/tasks/32_6.bin index 4c9b7758..c2a36fc6 100644 Binary files a/assets/tasks/32_6.bin and b/assets/tasks/32_6.bin differ diff --git a/assets/tasks/32_6.idx b/assets/tasks/32_6.idx new file mode 100644 index 00000000..3bbda9cd Binary files /dev/null and b/assets/tasks/32_6.idx differ diff --git a/assets/tasks/32_7.bin b/assets/tasks/32_7.bin index 2d524b4c..e2ba7352 100644 Binary files a/assets/tasks/32_7.bin and b/assets/tasks/32_7.bin differ diff --git a/assets/tasks/32_7.idx b/assets/tasks/32_7.idx new file mode 100644 index 00000000..5a72d2ce Binary files /dev/null and b/assets/tasks/32_7.idx differ diff --git a/assets/tasks/32_8.bin b/assets/tasks/32_8.bin index 6463110a..df9e61a7 100644 Binary files a/assets/tasks/32_8.bin and b/assets/tasks/32_8.bin differ diff --git a/assets/tasks/32_8.idx b/assets/tasks/32_8.idx new file mode 100644 index 00000000..a13490de Binary files /dev/null and b/assets/tasks/32_8.idx differ diff --git a/assets/tasks/32_9.bin b/assets/tasks/32_9.bin index d6ba3618..bbf39db1 100644 Binary files a/assets/tasks/32_9.bin and b/assets/tasks/32_9.bin differ diff --git a/assets/tasks/32_9.idx b/assets/tasks/32_9.idx new file mode 100644 index 00000000..bd3a68b4 Binary files /dev/null and b/assets/tasks/32_9.idx differ diff --git a/assets/tasks/33_0.bin b/assets/tasks/33_0.bin index a9eea3c3..18a8e567 100644 Binary files a/assets/tasks/33_0.bin and b/assets/tasks/33_0.bin differ diff --git a/assets/tasks/33_0.idx b/assets/tasks/33_0.idx new file mode 100644 index 00000000..03d4ec8c Binary files /dev/null and b/assets/tasks/33_0.idx differ diff --git a/assets/tasks/33_1.bin b/assets/tasks/33_1.bin index c1d4282e..6c262c8d 100644 Binary files a/assets/tasks/33_1.bin and b/assets/tasks/33_1.bin differ diff --git a/assets/tasks/33_1.idx b/assets/tasks/33_1.idx new file mode 100644 index 00000000..a863d21c Binary files /dev/null and b/assets/tasks/33_1.idx differ diff --git a/assets/tasks/33_2.bin b/assets/tasks/33_2.bin index e8304213..d61cb866 100644 Binary files a/assets/tasks/33_2.bin and b/assets/tasks/33_2.bin differ diff --git a/assets/tasks/33_2.idx b/assets/tasks/33_2.idx new file mode 100644 index 00000000..014d519a Binary files /dev/null and b/assets/tasks/33_2.idx differ diff --git a/assets/tasks/33_3.bin b/assets/tasks/33_3.bin index dde71473..06ec0299 100644 Binary files a/assets/tasks/33_3.bin and b/assets/tasks/33_3.bin differ diff --git a/assets/tasks/33_3.idx b/assets/tasks/33_3.idx new file mode 100644 index 00000000..04cb7500 Binary files /dev/null and b/assets/tasks/33_3.idx differ diff --git a/assets/tasks/33_4.bin b/assets/tasks/33_4.bin index 11d0918e..f0de6363 100644 Binary files a/assets/tasks/33_4.bin and b/assets/tasks/33_4.bin differ diff --git a/assets/tasks/33_4.idx b/assets/tasks/33_4.idx new file mode 100644 index 00000000..33ccbb21 Binary files /dev/null and b/assets/tasks/33_4.idx differ diff --git a/assets/tasks/33_5.bin b/assets/tasks/33_5.bin index bd8dd128..a2238758 100644 Binary files a/assets/tasks/33_5.bin and b/assets/tasks/33_5.bin differ diff --git a/assets/tasks/33_5.idx b/assets/tasks/33_5.idx new file mode 100644 index 00000000..f362ec56 Binary files /dev/null and b/assets/tasks/33_5.idx differ diff --git a/assets/tasks/33_6.bin b/assets/tasks/33_6.bin index 87bad634..0b2e2034 100644 Binary files a/assets/tasks/33_6.bin and b/assets/tasks/33_6.bin differ diff --git a/assets/tasks/33_6.idx b/assets/tasks/33_6.idx new file mode 100644 index 00000000..f0caac10 Binary files /dev/null and b/assets/tasks/33_6.idx differ diff --git a/assets/tasks/33_7.bin b/assets/tasks/33_7.bin index ecb0c5d4..33422722 100644 Binary files a/assets/tasks/33_7.bin and b/assets/tasks/33_7.bin differ diff --git a/assets/tasks/33_7.idx b/assets/tasks/33_7.idx new file mode 100644 index 00000000..3c59a050 Binary files /dev/null and b/assets/tasks/33_7.idx differ diff --git a/assets/tasks/33_8.bin b/assets/tasks/33_8.bin index 1ca990a0..82bb292b 100644 Binary files a/assets/tasks/33_8.bin and b/assets/tasks/33_8.bin differ diff --git a/assets/tasks/33_8.idx b/assets/tasks/33_8.idx new file mode 100644 index 00000000..245f4391 Binary files /dev/null and b/assets/tasks/33_8.idx differ diff --git a/assets/tasks/33_9.bin b/assets/tasks/33_9.bin index 2f1bb782..28e9747f 100644 Binary files a/assets/tasks/33_9.bin and b/assets/tasks/33_9.bin differ diff --git a/assets/tasks/33_9.idx b/assets/tasks/33_9.idx new file mode 100644 index 00000000..d806c6d6 Binary files /dev/null and b/assets/tasks/33_9.idx differ diff --git a/assets/tasks/34_0.bin b/assets/tasks/34_0.bin index dbb3a9e0..88a97eb1 100644 Binary files a/assets/tasks/34_0.bin and b/assets/tasks/34_0.bin differ diff --git a/assets/tasks/34_0.idx b/assets/tasks/34_0.idx new file mode 100644 index 00000000..b6bec14a Binary files /dev/null and b/assets/tasks/34_0.idx differ diff --git a/assets/tasks/34_1.bin b/assets/tasks/34_1.bin index c28dac19..78698dc8 100644 Binary files a/assets/tasks/34_1.bin and b/assets/tasks/34_1.bin differ diff --git a/assets/tasks/34_1.idx b/assets/tasks/34_1.idx new file mode 100644 index 00000000..36f45b2d Binary files /dev/null and b/assets/tasks/34_1.idx differ diff --git a/assets/tasks/34_2.bin b/assets/tasks/34_2.bin index 2d676480..f57b6142 100644 Binary files a/assets/tasks/34_2.bin and b/assets/tasks/34_2.bin differ diff --git a/assets/tasks/34_2.idx b/assets/tasks/34_2.idx new file mode 100644 index 00000000..830009a6 Binary files /dev/null and b/assets/tasks/34_2.idx differ diff --git a/assets/tasks/34_3.bin b/assets/tasks/34_3.bin index 5263b877..9e2ce6c4 100644 Binary files a/assets/tasks/34_3.bin and b/assets/tasks/34_3.bin differ diff --git a/assets/tasks/34_3.idx b/assets/tasks/34_3.idx new file mode 100644 index 00000000..a0a85655 Binary files /dev/null and b/assets/tasks/34_3.idx differ diff --git a/assets/tasks/34_4.bin b/assets/tasks/34_4.bin index d46bdc23..72762f5f 100644 Binary files a/assets/tasks/34_4.bin and b/assets/tasks/34_4.bin differ diff --git a/assets/tasks/34_4.idx b/assets/tasks/34_4.idx new file mode 100644 index 00000000..2b62d81e Binary files /dev/null and b/assets/tasks/34_4.idx differ diff --git a/assets/tasks/34_5.bin b/assets/tasks/34_5.bin index cb66cfaa..15e4fa50 100644 Binary files a/assets/tasks/34_5.bin and b/assets/tasks/34_5.bin differ diff --git a/assets/tasks/34_5.idx b/assets/tasks/34_5.idx new file mode 100644 index 00000000..d8e8942b Binary files /dev/null and b/assets/tasks/34_5.idx differ diff --git a/assets/tasks/34_6.bin b/assets/tasks/34_6.bin index 25a6fdc0..8f30f6cf 100644 Binary files a/assets/tasks/34_6.bin and b/assets/tasks/34_6.bin differ diff --git a/assets/tasks/34_6.idx b/assets/tasks/34_6.idx new file mode 100644 index 00000000..43a8fbf6 Binary files /dev/null and b/assets/tasks/34_6.idx differ diff --git a/assets/tasks/34_7.bin b/assets/tasks/34_7.bin index 842e38f4..e838e003 100644 Binary files a/assets/tasks/34_7.bin and b/assets/tasks/34_7.bin differ diff --git a/assets/tasks/34_7.idx b/assets/tasks/34_7.idx new file mode 100644 index 00000000..f16612cb Binary files /dev/null and b/assets/tasks/34_7.idx differ diff --git a/assets/tasks/34_8.bin b/assets/tasks/34_8.bin index d5a09473..2dd921e1 100644 Binary files a/assets/tasks/34_8.bin and b/assets/tasks/34_8.bin differ diff --git a/assets/tasks/34_8.idx b/assets/tasks/34_8.idx new file mode 100644 index 00000000..83035278 Binary files /dev/null and b/assets/tasks/34_8.idx differ diff --git a/assets/tasks/34_9.bin b/assets/tasks/34_9.bin index dc6a15b8..96f7b159 100644 Binary files a/assets/tasks/34_9.bin and b/assets/tasks/34_9.bin differ diff --git a/assets/tasks/34_9.idx b/assets/tasks/34_9.idx new file mode 100644 index 00000000..74f5e0bb Binary files /dev/null and b/assets/tasks/34_9.idx differ diff --git a/assets/tasks/35_0.bin b/assets/tasks/35_0.bin index dabd74d0..b3a15356 100644 Binary files a/assets/tasks/35_0.bin and b/assets/tasks/35_0.bin differ diff --git a/assets/tasks/35_0.idx b/assets/tasks/35_0.idx new file mode 100644 index 00000000..345541fb Binary files /dev/null and b/assets/tasks/35_0.idx differ diff --git a/assets/tasks/35_1.bin b/assets/tasks/35_1.bin index befff1ee..39f1049d 100644 Binary files a/assets/tasks/35_1.bin and b/assets/tasks/35_1.bin differ diff --git a/assets/tasks/35_1.idx b/assets/tasks/35_1.idx new file mode 100644 index 00000000..23290f45 Binary files /dev/null and b/assets/tasks/35_1.idx differ diff --git a/assets/tasks/35_2.bin b/assets/tasks/35_2.bin index 14b95210..e85549ae 100644 Binary files a/assets/tasks/35_2.bin and b/assets/tasks/35_2.bin differ diff --git a/assets/tasks/35_2.idx b/assets/tasks/35_2.idx new file mode 100644 index 00000000..96d26241 Binary files /dev/null and b/assets/tasks/35_2.idx differ diff --git a/assets/tasks/35_3.bin b/assets/tasks/35_3.bin index 5ec8b490..652b3ad3 100644 Binary files a/assets/tasks/35_3.bin and b/assets/tasks/35_3.bin differ diff --git a/assets/tasks/35_3.idx b/assets/tasks/35_3.idx new file mode 100644 index 00000000..e1eafe2c Binary files /dev/null and b/assets/tasks/35_3.idx differ diff --git a/assets/tasks/35_4.bin b/assets/tasks/35_4.bin index c35632d2..f273e244 100644 Binary files a/assets/tasks/35_4.bin and b/assets/tasks/35_4.bin differ diff --git a/assets/tasks/35_4.idx b/assets/tasks/35_4.idx new file mode 100644 index 00000000..3cd6f369 Binary files /dev/null and b/assets/tasks/35_4.idx differ diff --git a/assets/tasks/35_5.bin b/assets/tasks/35_5.bin index 345b6c73..dcfb5010 100644 Binary files a/assets/tasks/35_5.bin and b/assets/tasks/35_5.bin differ diff --git a/assets/tasks/35_5.idx b/assets/tasks/35_5.idx new file mode 100644 index 00000000..db071d04 Binary files /dev/null and b/assets/tasks/35_5.idx differ diff --git a/assets/tasks/35_6.bin b/assets/tasks/35_6.bin index f21d5826..78de795c 100644 Binary files a/assets/tasks/35_6.bin and b/assets/tasks/35_6.bin differ diff --git a/assets/tasks/35_6.idx b/assets/tasks/35_6.idx new file mode 100644 index 00000000..c7f640d1 Binary files /dev/null and b/assets/tasks/35_6.idx differ diff --git a/assets/tasks/35_7.bin b/assets/tasks/35_7.bin index 14b82dfe..b1bf8507 100644 Binary files a/assets/tasks/35_7.bin and b/assets/tasks/35_7.bin differ diff --git a/assets/tasks/35_7.idx b/assets/tasks/35_7.idx new file mode 100644 index 00000000..442d16c2 Binary files /dev/null and b/assets/tasks/35_7.idx differ diff --git a/assets/tasks/35_8.idx b/assets/tasks/35_8.idx new file mode 100644 index 00000000..0d2e38fc Binary files /dev/null and b/assets/tasks/35_8.idx differ diff --git a/assets/tasks/35_9.bin b/assets/tasks/35_9.bin index 622c9cee..ce994cdc 100644 Binary files a/assets/tasks/35_9.bin and b/assets/tasks/35_9.bin differ diff --git a/assets/tasks/35_9.idx b/assets/tasks/35_9.idx new file mode 100644 index 00000000..a773ba03 Binary files /dev/null and b/assets/tasks/35_9.idx differ diff --git a/assets/tasks/36_0.bin b/assets/tasks/36_0.bin index 3a24adbf..5c81a15c 100644 Binary files a/assets/tasks/36_0.bin and b/assets/tasks/36_0.bin differ diff --git a/assets/tasks/36_0.idx b/assets/tasks/36_0.idx new file mode 100644 index 00000000..f0a5b7b3 Binary files /dev/null and b/assets/tasks/36_0.idx differ diff --git a/assets/tasks/36_1.bin b/assets/tasks/36_1.bin index ec69b312..9b193b56 100644 Binary files a/assets/tasks/36_1.bin and b/assets/tasks/36_1.bin differ diff --git a/assets/tasks/36_1.idx b/assets/tasks/36_1.idx new file mode 100644 index 00000000..c8b5802d Binary files /dev/null and b/assets/tasks/36_1.idx differ diff --git a/assets/tasks/36_2.idx b/assets/tasks/36_2.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/36_3.bin b/assets/tasks/36_3.bin index 71ab14db..936152a2 100644 Binary files a/assets/tasks/36_3.bin and b/assets/tasks/36_3.bin differ diff --git a/assets/tasks/36_3.idx b/assets/tasks/36_3.idx new file mode 100644 index 00000000..7adb8712 Binary files /dev/null and b/assets/tasks/36_3.idx differ diff --git a/assets/tasks/36_4.idx b/assets/tasks/36_4.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/36_5.bin b/assets/tasks/36_5.bin index 056c1539..413d2cd8 100644 Binary files a/assets/tasks/36_5.bin and b/assets/tasks/36_5.bin differ diff --git a/assets/tasks/36_5.idx b/assets/tasks/36_5.idx new file mode 100644 index 00000000..0ed0626f Binary files /dev/null and b/assets/tasks/36_5.idx differ diff --git a/assets/tasks/36_6.bin b/assets/tasks/36_6.bin index 62ab1d1e..1ea9aad8 100644 Binary files a/assets/tasks/36_6.bin and b/assets/tasks/36_6.bin differ diff --git a/assets/tasks/36_6.idx b/assets/tasks/36_6.idx new file mode 100644 index 00000000..01b34b35 --- /dev/null +++ b/assets/tasks/36_6.idx @@ -0,0 +1 @@ +ߺ;?[ӣ^>}{{ߺ;?YӢZ>}{ߺ;?[ӢZ6}{30*.x3Q҂Z0-mA   \ No newline at end of file diff --git a/assets/tasks/36_7.bin b/assets/tasks/36_7.bin index fcd1969c..607b6aa0 100644 Binary files a/assets/tasks/36_7.bin and b/assets/tasks/36_7.bin differ diff --git a/assets/tasks/36_7.idx b/assets/tasks/36_7.idx new file mode 100644 index 00000000..7b54f8d2 Binary files /dev/null and b/assets/tasks/36_7.idx differ diff --git a/assets/tasks/36_8.idx b/assets/tasks/36_8.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/36_9.bin b/assets/tasks/36_9.bin index 66e6c7cf..0c024fc4 100644 Binary files a/assets/tasks/36_9.bin and b/assets/tasks/36_9.bin differ diff --git a/assets/tasks/36_9.idx b/assets/tasks/36_9.idx new file mode 100644 index 00000000..0eee07ed Binary files /dev/null and b/assets/tasks/36_9.idx differ diff --git a/assets/tasks/37_0.idx b/assets/tasks/37_0.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/37_1.idx b/assets/tasks/37_1.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/37_2.idx b/assets/tasks/37_2.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/37_3.idx b/assets/tasks/37_3.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/37_4.idx b/assets/tasks/37_4.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/37_5.idx b/assets/tasks/37_5.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/37_6.idx b/assets/tasks/37_6.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/37_7.idx b/assets/tasks/37_7.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/37_8.idx b/assets/tasks/37_8.idx new file mode 100644 index 00000000..e69de29b diff --git a/assets/tasks/37_9.idx b/assets/tasks/37_9.idx new file mode 100644 index 00000000..e69de29b 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..324e226f 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..393bfa88 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,5 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +# Output file for messages in template that are missing translations +untranslated-messages-file: untranslated-messages.txt \ 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..c3bd4b0b 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 { @@ -151,26 +152,29 @@ class _BoardState extends State { ), ), ]; - final board = MouseRegion( - cursor: widget.cursor, - onExit: _onPointerExit, - child: Listener( - onPointerDown: _onPointerDown, - onPointerHover: _onPointerHover, - child: SizedBox.square( - key: const ValueKey('board-container'), - dimension: widget.size, - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - background, - ...objects, - ], - ), - ), + Widget board = SizedBox.square( + key: const ValueKey('board-container'), + dimension: widget.size, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + background, + ...objects, + ], ), ); + if (widget.settings.interactive) { + board = MouseRegion( + cursor: widget.cursor, + onExit: _onPointerExit, + onHover: _onPointerHover, + child: GestureDetector( + onTapDown: _onTapDown, + child: board, + ), + ); + } if (widget.settings.border != null) { return Container( @@ -260,9 +264,8 @@ class _BoardState extends State { ]; } - void _onPointerDown(PointerDownEvent event) { - if (event.buttons != kPrimaryButton) return; - final wq.Point? p = widget.offsetPoint(event.localPosition); + void _onTapDown(TapDownDetails details) { + final p = widget.offsetPoint(details.localPosition); if (p == null) return; if (widget.confirmTap && boardIsLarge()) { if (widget.stones.containsKey(p)) { @@ -290,7 +293,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_annotation.dart b/lib/board/board_annotation.dart index 35d24a92..892a8972 100644 --- a/lib/board/board_annotation.dart +++ b/lib/board/board_annotation.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:extension_type_unions/extension_type_unions.dart'; enum AnnotationShape { @@ -9,6 +9,7 @@ enum AnnotationShape { triangle, territory, variation, + fill, } typedef Annotation = ({Union2 type, Color color}); @@ -98,16 +99,19 @@ class _AnnotationShapePainter extends CustomPainter { canvas.drawPath(path, paint); case AnnotationShape.territory: final center = Offset(size.width / 2, size.height / 2); - final paint = Paint() + final borderPaint = Paint() + ..style = PaintingStyle.stroke + ..color = Colors.grey; + final fillPaint = Paint() ..style = PaintingStyle.fill ..color = color; - canvas.drawRect( - Rect.fromCenter( - center: center, - width: size.width / 2.5, - height: size.height / 2.5, - ), - paint); + final rect = Rect.fromCenter( + center: center, + width: size.width / 2.5, + height: size.height / 2.5, + ); + canvas.drawRect(rect, fillPaint); + canvas.drawRect(rect, borderPaint); case AnnotationShape.variation: final paint = Paint() ..style = PaintingStyle.fill @@ -118,6 +122,18 @@ class _AnnotationShapePainter extends CustomPainter { path.lineTo(side, 0); path.close(); canvas.drawPath(path, paint); + case AnnotationShape.fill: + final paint = Paint() + ..style = PaintingStyle.fill + ..color = color; + canvas.drawRect( + Rect.fromLTWH( + 0, + 0, + size.width, + size.height, + ), + paint); } } 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_settings.dart b/lib/board/board_settings.dart index 897731ff..3e5d5656 100644 --- a/lib/board/board_settings.dart +++ b/lib/board/board_settings.dart @@ -73,6 +73,7 @@ class BoardSettings { this.edgeLine = BoardEdgeLine.single, this.border, this.stoneShadows = true, + this.interactive = true, }); final int size; @@ -81,6 +82,7 @@ class BoardSettings { final BoardEdgeLine edgeLine; final BoardBorderSettings? border; final bool stoneShadows; + final bool interactive; int get visibleSize => subBoard?.size ?? size; (int, int) get topLeft => subBoard?.topLeft ?? (0, 0); @@ -90,8 +92,8 @@ class BoardSettings { } @override - int get hashCode => - Object.hash(size, subBoard, theme, edgeLine, border, stoneShadows); + int get hashCode => Object.hash( + size, subBoard, theme, edgeLine, border, stoneShadows, interactive); @override bool operator ==(Object other) { @@ -107,6 +109,7 @@ class BoardSettings { other.theme == theme && other.edgeLine == edgeLine && other.border == border && - other.stoneShadows == stoneShadows; + other.stoneShadows == stoneShadows && + other.interactive == interactive; } } 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/cancellable_isolate_stream.dart b/lib/cancellable_isolate_stream.dart new file mode 100644 index 00000000..95bea2f2 --- /dev/null +++ b/lib/cancellable_isolate_stream.dart @@ -0,0 +1,8 @@ +import 'dart:async'; + +class CancellableIsolateStream { + final Stream stream; + final void Function() cancel; + + const CancellableIsolateStream({required this.stream, required this.cancel}); +} 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/counting_result.dart b/lib/game_client/counting_result.dart index 5411f29b..f44edf58 100644 --- a/lib/game_client/counting_result.dart +++ b/lib/game_client/counting_result.dart @@ -6,15 +6,17 @@ class CountingResult { final wq.Color winner; final double scoreLead; final List> ownership; + final bool isFinal; const CountingResult({ required this.winner, required this.scoreLead, required this.ownership, + required this.isFinal, }); @override - int get hashCode => Object.hash(winner, scoreLead, ownership); + int get hashCode => Object.hash(winner, scoreLead, ownership, isFinal); @override bool operator ==(Object other) { @@ -23,6 +25,17 @@ class CountingResult { return other is CountingResult && other.winner == winner && other.scoreLead == scoreLead && - other.ownership == ownership; + other.isFinal == isFinal && + _eqOwnership(other.ownership, ownership); + } + + bool _eqOwnership(List> x, List> y) { + if (x.length != y.length) return false; + for (int i = 0; i < x.length; ++i) { + if (x[i].length != y[i].length) return false; + for (int j = 0; j < x[i].length; ++j) + if (x[i][j] != y[i][j]) return false; + } + return true; } } 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.dart b/lib/game_client/game.dart index edd13bd5..8fb40c11 100644 --- a/lib/game_client/game.dart +++ b/lib/game_client/game.dart @@ -50,9 +50,9 @@ abstract class Game { Future pass(); Future resign(); Future automaticCounting(); - Future manualCounting(); Future aiReferee(); Future forceCounting(); Future agreeToAutomaticCounting(bool agree); Future acceptCountingResult(bool agree); + Future toggleManuallyRemovedStones(List stones, bool removed); } diff --git a/lib/game_client/game_client_list.dart b/lib/game_client/game_client_list.dart new file mode 100644 index 00000000..c35a3087 --- /dev/null +++ b/lib/game_client/game_client_list.dart @@ -0,0 +1,16 @@ +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", + aiServerUrl: kDebugMode + ? "https://beta-ai.online-go.com" + : "https://ai.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..15025f95 --- /dev/null +++ b/lib/game_client/game_client_list.dart.PROD @@ -0,0 +1,17 @@ +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(), + OGSGameClient( + serverUrl: "https://online-go.com", + aiServerUrl: "https://ai.online-go.com", + ), + TygemGameClient(), + if (kDebugMode) TestGameClient(), +]); + diff --git a/lib/game_client/game_record.dart b/lib/game_client/game_record.dart index 8d82f8f7..cdbdac96 100644 --- a/lib/game_client/game_record.dart +++ b/lib/game_client/game_record.dart @@ -17,23 +17,52 @@ 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]; + + // Handle handicap stones or other setup at the root node + if (i == 0 && moves.isEmpty) { + // "Add Black" + if (node.containsKey('AB')) { + for (final stoneStr in node['AB']!) { + moves.add((col: wq.Color.black, p: wq.parseSgfPoint(stoneStr))); + } + } + // "Add White" + if (node.containsKey('AW')) { + for (final stoneStr in node['AW']!) { + moves.add((col: wq.Color.white, p: wq.parseSgfPoint(stoneStr))); + } + } + 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/chat_presence_manager.dart b/lib/game_client/ogs/chat_presence_manager.dart new file mode 100644 index 00000000..002fed86 --- /dev/null +++ b/lib/game_client/ogs/chat_presence_manager.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:wqhub/game_client/ogs/ogs_websocket_manager.dart'; + +/// Manages chat presence tracking for a specific channel. +/// +/// The ChatPresenceManager serves two main roles: +/// +/// 1. Join and leave the chat channel +/// - This is necessary for OGS to track online status of players in games +/// which it uses to allow or disallow participation in scoring. +/// 2. Track users joining and leaving the channel +class ChatPresenceManager { + final Logger _logger = Logger('ChatPresenceManager'); + final String channel; + final OGSWebSocketManager _webSocketManager; + final Set _presentUsers = {}; + final StreamController> _presenceController = + StreamController>.broadcast(); + StreamSubscription>? _messageSubscription; + + ChatPresenceManager({ + required this.channel, + required OGSWebSocketManager webSocketManager, + }) : _webSocketManager = webSocketManager { + _messageSubscription = _webSocketManager.messages.listen(_handleMessage); + _webSocketManager.send('chat/join', {'channel': channel}); + _logger + .fine('ChatPresenceManager initialized and joined channel: $channel'); + } + + /// Returns true if the user with the given ID is currently present in the chat. + bool isPresent(String userId) { + return _presentUsers.contains(userId); + } + + /// Stream of presence updates. + /// + /// Emits the current set of present user IDs whenever the presence changes. + Stream> get presenceUpdates => _presenceController.stream; + + void _handleMessage(Map message) { + final event = message['event'] as String; + final data = message['data'] as Map; + + final messageChannel = data['channel'] as String?; + if (messageChannel != channel) { + return; + } + + switch (event) { + case 'chat-join': + _handleChatJoin(data); + case 'chat-part': + _handleChatPart(data); + } + } + + void _handleChatJoin(Map data) { + final users = data['users'] as List; + + var changed = false; + for (final user in users) { + if (user is Map) { + final userId = user['id'].toString(); + if (_presentUsers.add(userId)) { + changed = true; + _logger.fine('User $userId joined channel $channel'); + } + } + } + + if (changed) { + _presenceController.add(Set.unmodifiable(_presentUsers)); + } + } + + void _handleChatPart(Map data) { + final user = data['user'] as Map?; + if (user == null) { + return; + } + + final userId = user['id'].toString(); + if (_presentUsers.remove(userId)) { + _logger.fine('User $userId left channel $channel'); + _presenceController.add(Set.unmodifiable(_presentUsers)); + } + } + + /// Cleans up resources and stops listening to messages. + void dispose() { + _messageSubscription?.cancel(); + _webSocketManager.send('chat/part', {'channel': channel}); + _presenceController.close(); + _presentUsers.clear(); + _logger.fine('ChatPresenceManager disposed and left channel: $channel'); + } +} diff --git a/lib/game_client/ogs/game_utils.dart b/lib/game_client/ogs/game_utils.dart new file mode 100644 index 00000000..88ce2cbc --- /dev/null +++ b/lib/game_client/ogs/game_utils.dart @@ -0,0 +1,62 @@ +import 'package:wqhub/wq/wq.dart' as wq; + +/// Returns formatted result string with winner prefix (e.g., "B + Resignation", "W + 5.5 points"). +String formatGameResult(wq.Color? winner, String outcome) { + String winnerPrefix = switch (winner) { + wq.Color.black => 'B + ', + wq.Color.white => 'W + ', + null => '', + }; + return '$winnerPrefix$outcome'; +} + +/// Parse a string of SGF coordinates into a list of points. +/// +/// Parameters: +/// - [stonesString]: String containing concatenated SGF coordinates (e.g., "aabbcc") +/// +/// Returns a list of points parsed from the SGF coordinates. +List parseStonesString(String stonesString) { + final points = []; + + for (int i = 0; i + 1 < stonesString.length; i += 2) { + final sgfCoord = stonesString.substring(i, i + 2); + final point = wq.parseSgfPoint(sgfCoord); + points.add(point); + } + + return points; +} + +/// Determine whose color it is to move given the number of moves played and +/// the game starting conditions. +/// +/// Parameters: +/// - [moveNumber]: Number of moves already played (typically MoveTree.moveNumber) +/// - [handicap]: Number of handicap stones (default 0) +/// - [freeHandicapPlacement]: True when handicap stones are placed during play; +/// default false. +/// +/// Returns the color that should play next. +wq.Color colorToMove( + int moveNumber, { + int handicap = 0, + bool freeHandicapPlacement = false, +}) { + // When handicap is 0 (even game) or 1 (no komi), black goes first + // For greater handicaps: + // - With free placement, black plays first for each handicap stone + // - Without free placement, white plays first (black's stones are pre-placed) + // Once all handicap stones are placed, the game alternates as normal + if (handicap <= 1) { + return moveNumber.isEven ? wq.Color.black : wq.Color.white; + } else { + if (freeHandicapPlacement) { + moveNumber -= handicap; + if (moveNumber < 0) { + return wq.Color.black; + } + } + return moveNumber.isEven ? wq.Color.white : wq.Color.black; + } +} diff --git a/lib/game_client/ogs/http_client.dart b/lib/game_client/ogs/http_client.dart new file mode 100644 index 00000000..89b2e7ee --- /dev/null +++ b/lib/game_client/ogs/http_client.dart @@ -0,0 +1,199 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// A thin abstraction over the HTTP client for API calls. +/// +/// Handles common patterns like: +/// - Server URL management +/// - CSRF token handling +/// - Common headers +/// - API versioning +/// - JSON encoding/decoding +class HttpClient { + static const String _userAgent = 'WeiqiHub/1.0'; + + final String serverUrl; + final http.Client _httpClient; + final int? defaultApiVersion; + + String? csrfToken; + + HttpClient({ + required this.serverUrl, + http.Client? httpClient, + this.defaultApiVersion = 1, + }) : _httpClient = httpClient ?? http.Client(); + + /// Makes a GET request and returns the parsed JSON response + Future> getJson( + String path, { + Map? queryParameters, + int? apiVersion, + }) async { + final response = await _get(path, + queryParameters: queryParameters, apiVersion: apiVersion); + return jsonDecode(response.body) as Map; + } + + /// Makes a GET request and returns the raw text response + Future getText( + String path, { + Map? queryParameters, + int? apiVersion, + }) async { + final response = await _get(path, + queryParameters: queryParameters, apiVersion: apiVersion); + return response.body; + } + + /// Makes a GET request and returns the raw HTTP response + Future _get( + String path, { + Map? queryParameters, + int? apiVersion, + }) async { + final uri = _buildUri(path, queryParameters, apiVersion); + + final response = await _httpClient.get( + uri, + headers: _buildHeaders(), + ); + + _checkResponse(response); + return response; + } + + /// Makes a POST request with JSON payload and returns the parsed JSON response + Future> postJson( + String path, + Map data, { + Map? queryParameters, + int? apiVersion, + }) async { + final response = await _post(path, data, + queryParameters: queryParameters, apiVersion: apiVersion); + return jsonDecode(response.body) as Map; + } + + /// Makes a POST request with JSON payload and returns the raw HTTP response + Future _post( + String path, + Map data, { + Map? queryParameters, + int? apiVersion, + }) async { + final uri = _buildUri(path, queryParameters, apiVersion); + + final response = await _httpClient.post( + uri, + headers: _buildHeaders(includeContentType: true, includeCsrf: true), + body: jsonEncode(data), + ); + + _checkResponse(response); + return response; + } + + /// Makes a PUT request with JSON payload and returns the parsed JSON response + Future> putJson( + String path, + Map data, { + Map? queryParameters, + int? apiVersion, + }) async { + final response = await _put(path, data, + queryParameters: queryParameters, apiVersion: apiVersion); + return jsonDecode(response.body) as Map; + } + + /// Makes a PUT request with JSON payload and returns the raw HTTP response + Future _put( + String path, + Map data, { + Map? queryParameters, + int? apiVersion, + }) async { + final uri = _buildUri(path, queryParameters, apiVersion); + + final response = await _httpClient.put( + uri, + headers: _buildHeaders(includeContentType: true, includeCsrf: true), + body: jsonEncode(data), + ); + + _checkResponse(response); + return response; + } + + /// Builds the complete URI for a given path and query parameters + Uri _buildUri( + String path, Map? queryParameters, int? apiVersion) { + // Use provided apiVersion, or default, or none + final version = apiVersion ?? defaultApiVersion; + + String fullPath = version != null + ? '$serverUrl/api/v$version$path' + : '$serverUrl/api$path'; + + final uri = Uri.parse(fullPath); + + if (queryParameters != null && queryParameters.isNotEmpty) { + return uri.replace(queryParameters: queryParameters); + } + + return uri; + } + + /// Builds the standard headers for requests + Map _buildHeaders({ + bool includeContentType = false, + bool includeCsrf = false, + }) { + final headers = { + 'User-Agent': _userAgent, + }; + + if (includeContentType) { + headers['Content-Type'] = 'application/json'; + } + + if (includeCsrf && csrfToken != null) { + headers['X-CSRFToken'] = csrfToken!; + } + + return headers; + } + + /// Checks the HTTP response and throws an exception for error status codes + void _checkResponse(http.Response response) { + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpStatusException( + 'HTTP ${response.statusCode}: ${response.reasonPhrase}', + statusCode: response.statusCode, + responseBody: response.body, + ); + } + } + + /// Disposes of the underlying HTTP client + void dispose() { + _httpClient.close(); + } +} + +/// Exception thrown when an HTTP request fails with a non-2xx status code +class HttpStatusException implements Exception { + final String message; + final int statusCode; + final String responseBody; + + const HttpStatusException( + this.message, { + required this.statusCode, + required this.responseBody, + }); + + @override + String toString() => 'HttpStatusException: $message'; +} diff --git a/lib/game_client/ogs/ogs_game.dart b/lib/game_client/ogs/ogs_game.dart new file mode 100644 index 00000000..f7ca46fe --- /dev/null +++ b/lib/game_client/ogs/ogs_game.dart @@ -0,0 +1,722 @@ +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/chat_presence_manager.dart'; +import 'package:wqhub/game_client/ogs/http_client.dart'; +import 'package:wqhub/game_client/ogs/game_utils.dart'; +import 'package:wqhub/game_client/ogs/ogs_websocket_manager.dart'; +import 'package:wqhub/game_client/rules.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; + final String _jwtToken; + final HttpClient _aiHttpClient; + final bool _freeHandicapPlacement; + late final StreamController _moveController; + late final StreamController _countingResultController; + late final StreamController _countingResultResponsesController; + late final Completer _resultCompleter; + StreamSubscription>? _messageSubscription; + late final ChatPresenceManager _chatPresenceManager; + StreamSubscription>? _presenceSubscription; + + // 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 = ''; + + // Track current phase to detect transitions + String _currentPhase = "play"; + + 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, + required String jwtToken, + required String aiServerUrl, + required bool freeHandicapPlacement, + }) : _webSocketManager = webSocketManager, + _myUserId = myUserId, + _jwtToken = jwtToken, + _aiHttpClient = + HttpClient(serverUrl: aiServerUrl, defaultApiVersion: null), + _freeHandicapPlacement = freeHandicapPlacement { + _logger.info('Initialized OGSGame with id: $id'); + _moveController = StreamController.broadcast(); + _countingResultController = StreamController.broadcast(); + _countingResultResponsesController = StreamController.broadcast(); + _resultCompleter = Completer(); + + _allMoves.addAll(previousMoves); + + // Initialize chat presence manager - this will auto-join the chat channel + _chatPresenceManager = ChatPresenceManager( + channel: 'game-$id', + webSocketManager: _webSocketManager, + ); + _presenceSubscription = + _chatPresenceManager.presenceUpdates.listen(_handlePresenceUpdate); + + _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; + } + } + + Future toggleManuallyRemovedStones( + List stones, bool removed) async { + if (_currentPhase != 'stone removal') { + _logger.warning( + 'toggleManuallyRemovedStones called outside of stone removal phase'); + return; + } + + try { + final stonesString = stones.map((point) => point.toSgf()).join(); + + _webSocketManager.send('game/removed_stones/set', { + 'game_id': int.parse(id), + 'stones': stonesString, + 'removed': removed, + }); + + _logger.fine( + 'Sent stone removal proposal for game $id: stones=$stonesString, removed=$removed'); + } catch (error) { + _logger.warning( + 'Failed to send stone removal proposal 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() => + _countingResultResponsesController.stream; + + @override + Stream countingResults() => _countingResultController.stream; + + @override + Future forceCounting() => 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); + } + + @override + Future resign() async { + try { + _webSocketManager.send('game/resign', { + 'game_id': int.parse(id), + }); + } catch (error) { + _logger.warning('Failed to send resignation for game $id: $error'); + rethrow; + } + } + + 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 'removed_stones': + _handleRemovedStonesSet(message['data'] as Map); + + case 'move': + _handleMove(message['data'] as Map); + + case 'clock': + _handleClock(message['data'] as Map); + + case 'phase': + _handlePhase(message['data']); + + default: + _logger.fine('Unhandled game event for game $id: $suffix'); + } + } + + void _handleError(dynamic data) { + _logger.warning('Error received for game $id: $data'); + } + + void _handlePresenceUpdate(Set presentUsers) { + _logger.fine('Presence update: ${presentUsers.length} users present'); + + final blackUserId = black.value.userId; + final whiteUserId = white.value.userId; + + final blackOnline = presentUsers.contains(blackUserId); + final whiteOnline = presentUsers.contains(whiteUserId); + + if (black.value.online != blackOnline) { + black.value = black.value.copyWith(online: blackOnline); + _logger.fine('Updated black player presence: $blackOnline'); + } + + if (white.value.online != whiteOnline) { + white.value = white.value.copyWith(online: whiteOnline); + _logger.fine('Updated white player presence: $whiteOnline'); + } + } + + 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'); + + final color = colorToMove( + // move number from the server is 1-indexed + moveNumber - 1, + handicap: handicap, + freeHandicapPlacement: _freeHandicapPlacement); + + 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); + } + + // Check for phase changes + final phase = gameData['phase'] as String; + _updatePhase(phase); + + if (phase == 'finished' && !_resultCompleter.isCompleted) { + // Update game result if the game has ended + 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: _chatPresenceManager.isPresent(userId), + 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; + } + } + + return GameResult( + winner: winner, + result: formatGameResult(winner, outcome), + 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; + } + + // Signal that opponent accepted the counting result + _countingResultResponsesController.add(true); + } catch (error) { + _logger.warning( + 'Error handling removed stones accepted for game $id: $error'); + } + } + + void _handleRemovedStonesSet(Map data) { + _logger.fine('Received removed stones set for game $id'); + + try { + _recentlyRemovedStonesString = data['all_removed'] as String? ?? ''; + final countingResult = _calculateCountingResultFromOwnership(data); + _countingResultController.add(countingResult); + } catch (error) { + _logger.warning('Error handling removed stones set 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, + ); + } + + void _handlePhase(dynamic data) { + _logger.fine('Received phase message for game $id: $data'); + + try { + final phase = data as String; + _updatePhase(phase); + } catch (error) { + _logger.warning('Error handling phase message for game $id: $error'); + } + } + + void _updatePhase(String phase) { + if (phase != _currentPhase) { + if (_currentPhase == 'stone removal' && phase == 'play') { + // Transition from counting/stone removal back to play - this indicates + // that one of the players sent the game/removed_stones/reject message + _countingResultResponsesController.add(false); + } else if (_currentPhase == 'play' && phase == 'stone removal') { + // Transition from play to stone removal - call AI server for auto-scoring + _setAIRemovedStones(); + } + _currentPhase = phase; + _logger.fine('Phase changed to: $phase'); + } + } + + /// Calculate territory ownership and score from OGS ownership data + CountingResult _calculateCountingResultFromOwnership( + Map data) { + _logger.fine('Calculating area ownership from OGS territory data'); + + final ownershipData = data['ownership'] as List; + + // First create ownership grid from OGS territory data + 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, + }; + }); + + // Parse removed stones from the message + final allRemovedString = data['all_removed'] as String? ?? ''; + final removedStones = parseStonesString(allRemovedString).toSet(); + + _logger.fine( + 'Removed stones from message: $allRemovedString -> ${removedStones.length} stones'); + + 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; + + // Ensure living stones are marked as owned by their respective colors + _ensureLivingStonesMarkedInOwnership(ownership, removedStones); + + _logger.fine( + 'Score from ownership: Black=$blackScore (area: $blackTerritory, captures: $whiteCaptures), ' + 'White=$whiteScore (area: $whiteTerritory, captures: $blackCaptures, komi: $komi)'); + _logger.fine('Winner: $winner, Lead: $scoreLead'); + + return CountingResult( + winner: winner, + scoreLead: scoreLead, + ownership: ownership, + isFinal: false, + ); + } + + /// Convert current game state to 2D array format expected by AI server + /// 0 = empty, 1 = black, 2 = white + List> _getCurrentBoardStateForAIServer() { + final boardState = BoardState(size: boardSize); + + // Replay all moves to get current board state + for (final move in _allMoves) { + boardState.move(move); + } + + final board = generate2D( + boardSize, + (row, col) => switch (boardState[(row, col)]) { + null => 0, + wq.Color.black => 1, + wq.Color.white => 2, + }); + + return board; + } + + /// Gives the rules string expected by OGS APIs + String _rulesToOgsString(Rules rules) => switch (rules) { + Rules.chinese => 'chinese', + Rules.japanese => 'japanese', + Rules.korean => 'korean', + }; + + /// Gets a score evaluation from the server + Future?> _callAIServer( + List> boardState, String playerToMove) async { + _logger.fine('Calling AI server for game $id'); + + final payload = { + 'player_to_move': playerToMove, + 'width': boardSize, + 'height': boardSize, + 'rules': _rulesToOgsString(rules), + 'board_state': boardState, + 'autoscore': true, + 'jwt': _jwtToken, + }; + + try { + final responseData = await _aiHttpClient.postJson('/score', payload); + _logger.fine('AI server response received for game $id'); + return responseData; + } catch (e) { + _logger.warning('AI server request failed for game $id: $e'); + return null; + } + } + + Future _setAIRemovedStones() async { + _logger.fine('Requesting AI server analysis for game $id'); + + // Get current board state and determine whose turn it is + final boardState = _getCurrentBoardStateForAIServer(); + final playerToMove = myColor == wq.Color.black ? 'black' : 'white'; + + // Call AI server + final aiResponse = await _callAIServer(boardState, playerToMove); + + if (aiResponse == null) { + _logger.warning('AI server returned null response for game $id'); + return; + } + + _logger.fine('Sending removed stones from AI server for game $id'); + + // Extract removed stones from AI response + final removedStones = + aiResponse['autoscored_removed'] as List? ?? []; + + // Convert AI response format to OGS stones string format + // AI returns: [{"x":7,"y":2,"removal_reason":"..."}] + final stonesList = []; + for (final stone in removedStones) { + if (stone is Map) { + final x = stone['x'] as int?; + final y = stone['y'] as int?; + if (x != null && y != null) { + // Convert to SGF coordinate format + final point = + (y, x); // Note: AI response uses (x,y) but our Point is (row,col) + stonesList.add(point.toSgf()); + } + } + } + + final stonesString = stonesList.join(''); + + // Sending two set messages is a bit strange, but it's the only way to reset + // the board. Compare with OGS code: + // https://github.com/online-go/goban/blob/097d741f092387a2067ac40357e566038c3453ee/src/Goban/OGSConnectivity.ts#L1431-L1442 + // The first message clears any previously staged removed stones + _webSocketManager.send('game/removed_stones/set', { + 'game_id': int.parse(id), + 'removed': false, + 'stones': _recentlyRemovedStonesString, + }); + // The second message sends the new set of removed stones + _webSocketManager.send('game/removed_stones/set', { + 'game_id': int.parse(id), + 'removed': true, + 'stones': stonesString, + }); + _logger.fine('Sent removed stones to server for game $id: $stonesString'); + } + + /// Ensures that living stones are marked as owned by their respective colors in the ownership grid. + /// + /// OGS ownership may represent territory (stones are neutral) or area, depending on ruleset. + /// WQHub ownership expects ownership to represent area regardless (stones belong to their color). + void _ensureLivingStonesMarkedInOwnership( + List> ownership, Set deadStones) { + // Reconstruct the current board state to identify living stones + final currentBoardState = BoardState(size: boardSize); + for (final move in _allMoves) { + currentBoardState.move(move); + } + + // Mark living stones as owned by their respective colors + for (int i = 0; i < boardSize; i++) { + for (int j = 0; j < boardSize; j++) { + final point = (i, j); + final stoneColor = currentBoardState[point]; + if (stoneColor != null && !deadStones.contains(point)) { + // This point has a living stone (not removed), mark it as owned by the stone's color + ownership[i][j] = stoneColor; + } + } + } + } + + void dispose() { + _messageSubscription?.cancel(); + _presenceSubscription?.cancel(); + _chatPresenceManager.dispose(); + _webSocketManager.leaveGame(id); + _moveController.close(); + _countingResultController.close(); + _countingResultResponsesController.close(); + _aiHttpClient.dispose(); + + // Complete the result future if not already completed + if (!_resultCompleter.isCompleted) { + _resultCompleter.completeError('Game disposed before completion'); + } + } + + @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..70188d5e --- /dev/null +++ b/lib/game_client/ogs/ogs_game_client.dart @@ -0,0 +1,567 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.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/game_client/game_record.dart'; +import 'package:wqhub/game_client/game_result.dart'; +import 'package:wqhub/game_client/ogs/game_utils.dart'; +import 'package:wqhub/game_client/ogs/ogs_game.dart'; +import 'package:wqhub/game_client/ogs/http_client.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'); + + /// 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; + final String aiServerUrl; + late final OGSWebSocketManager _webSocketManager; + + OGSGameClient({required this.serverUrl, required this.aiServerUrl}) { + _httpClient = HttpClient(serverUrl: serverUrl, defaultApiVersion: 1); + _webSocketManager = OGSWebSocketManager(serverUrl: serverUrl); + + // Listen to WebSocket connection status + _webSocketManager.connected.addListener(() { + if (!_webSocketManager.connected.value) { + _disconnected.value = DateTime.now(); + } + }); + } + + String? _jwtToken; + + String? _currentAutomatchUuid; + + Completer? _automatchCompleter; + StreamSubscription>? _messageSubscription; + late final HttpClient _httpClient; + + @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: false, + 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 csrfData = await _httpClient.getJson('/ui/config'); + final csrfToken = csrfData['csrf_token'] as String?; + _httpClient.csrfToken = csrfToken; + + // Now attempt login + final loginData = await _httpClient.postJson( + '/login', + { + 'username': username, + 'password': password, + }, + apiVersion: 0); + + 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; + } catch (e) { + throw Exception('Login error: $e'); + } + } + + @override + void logout() { + _httpClient.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 data = await _httpClient.getJson( + '/players/${userInfo.userId}/games/', + queryParameters: { + 'page_size': '10', + 'page': '1', + 'source': 'play', + 'ended__isnull': 'true', + 'time_per_move__lt': '3600', + }, + ); + 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 responseData = await _httpClient.getJson('/games/$gameId'); + final gameData = responseData['gamedata'] as Map; + + // Parse game information + final boardSize = gameData['width'] as int? ?? 19; + final handicap = gameData['handicap'] as int? ?? 0; + final freeHandicapPlacement = + gameData['free_handicap_placement'] as bool? ?? false; + 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, handicap, freeHandicapPlacement); + + return OGSGame( + id: gameId, + boardSize: boardSize, + rules: rules, + handicap: handicap, + komi: komi, + myColor: myColor, + timeControl: timeControl, + previousMoves: previousMoves, + webSocketManager: _webSocketManager, + myUserId: userInfo.userId, + freeHandicapPlacement: freeHandicapPlacement, + jwtToken: _jwtToken ?? '', + aiServerUrl: aiServerUrl, + ); + } + + @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 data = await _httpClient.getJson( + '/players/${userInfo.userId}/games/', + queryParameters: { + 'page_size': '50', + 'page': '1', + 'source': 'play', + 'ended__isnull': 'false', + 'ordering': '-ended', + }, + ); + 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: formatGameResult(winner, 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 sgfContent = await _httpClient.getText('/games/$gameId/sgf'); + 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, int handicap, bool freeHandicapPlacement) { + final moves = []; + + // Extract initial_state - this powers "forked" games, and, more importantly, handicap stones + final initialState = gameData['initial_state'] as Map?; + if (initialState != null) { + moves.addAll(parseStonesString(initialState['black'] as String? ?? "") + .map((point) => (col: wq.Color.black, p: point))); + moves.addAll(parseStonesString(initialState['white'] as String? ?? "") + .map((point) => (col: wq.Color.white, p: point))); + } + + final movesData = gameData['moves'] as List?; + if (movesData == null) { + return moves; + } + + for (int i = 0; i < movesData.length; i++) { + final moveData = movesData[i]; + + final color = colorToMove(i, + handicap: handicap, freeHandicapPlacement: freeHandicapPlacement); + + 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.dispose(); + _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..0ca425a0 --- /dev/null +++ b/lib/game_client/ogs/ogs_websocket_manager.dart @@ -0,0 +1,303 @@ +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)}); + _log.fine('Joining game: $gameId'); + } + + void leaveGame(String gameId) { + send('game/disconnect', {'game_id': int.parse(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.dart b/lib/game_client/test_game.dart index 404e56ff..24d76345 100644 --- a/lib/game_client/test_game.dart +++ b/lib/game_client/test_game.dart @@ -16,7 +16,9 @@ class TestGame extends Game { required super.myColor, required super.timeControl, required super.previousMoves, - }); + }) : _countingResultController = StreamController.broadcast(); + + final StreamController _countingResultController; @override Future acceptCountingResult(bool agree) => Future.value(); @@ -38,13 +40,15 @@ class TestGame extends Game { Stream countingResultResponses() => Stream.empty(); @override - Stream countingResults() => Stream.empty(); + Stream countingResults() => _countingResultController.stream; @override - Future forceCounting() => Future.value(); + Future toggleManuallyRemovedStones( + List stones, bool removed) => + Future.value(); @override - Future manualCounting() => Future.value(); + Future forceCounting() => Future.value(); @override Future move(wq.Move move) => Future.value(); @@ -53,7 +57,15 @@ class TestGame extends Game { Stream moves() => Stream.empty(); @override - Future pass() => Future.value(); + Future pass() { + _countingResultController.add(CountingResult( + winner: wq.Color.black, + scoreLead: 0, + ownership: List.empty(), + isFinal: false, + )); + return Future.value(); + } @override Future resign() => Future.value(); diff --git a/lib/game_client/test_game_client.dart b/lib/game_client/test_game_client.dart index 903614de..676c1c03 100644 --- a/lib/game_client/test_game_client.dart +++ b/lib/game_client/test_game_client.dart @@ -44,7 +44,16 @@ class TestGameClient extends GameClient { periodCount: 3, timePerPeriod: Duration(seconds: 30), ), - previousMoves: [], + previousMoves: [ + (col: wq.Color.black, p: (3, 3)), + (col: wq.Color.black, p: (3, 4)), + (col: wq.Color.black, p: (5, 4)), + (col: wq.Color.white, p: (3, 15)), + (col: wq.Color.black, p: (15, 3)), + (col: wq.Color.white, p: (15, 15)), + (col: wq.Color.black, p: (2, 5)), + (col: wq.Color.white, p: (16, 13)), + ], ); testGame.black.value = UserInfo( userId: 'test_black_id', @@ -110,9 +119,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..56766a94 --- /dev/null +++ b/lib/help/ranked_mode_help_dialog.dart @@ -0,0 +1,61 @@ +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, + context.settings.randomizeTaskOrientation), + 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..9239111c --- /dev/null +++ b/lib/help/time_frenzy_help_dialog.dart @@ -0,0 +1,62 @@ +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( + randomizeLayout: + context.settings.randomizeTaskOrientation), + 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_de.arb b/lib/l10n/app_de.arb new file mode 100644 index 00000000..f1ad5e70 --- /dev/null +++ b/lib/l10n/app_de.arb @@ -0,0 +1,518 @@ +{ + "about": "Über", + "acceptDeadStones": "Tote Steine akzeptieren", + "accuracy": "Genauigkeit", + "aiReferee": "KI-Schiedsrichter", + "aiSensei": "AI Sensei", + "alwaysBlackToPlay": "Immer Schwarz am Zug", + "alwaysBlackToPlayDesc": "Alle Aufgaben mit Schwarz am Zug aufbauen, um Verwechslung zu vermeiden", + "appearance": "Aussehen", + "autoCounting": "Automatisches Auszählen", + "autoMatch": "Automatisches Match", + "behaviour": "Verhalten", + "bestResult": "Bestes Ergebnis", + "black": "Schwarz", + "board": "Brett", + "boardSize": "Brettgröße", + "boardTheme": "Brett Aussehen", + "byRank": "Nach Rang", + "cancel": "Abbrechen", + "captures": "Gefangene", + "clearBoard": "Brett leeren", + "collectStats": "Statistiken erfassen", + "collections": "Sammlungen", + "confirm": "Bestätigen", + "confirmBoardSize": "Brettgröße bestätigen", + "confirmBoardSizeDesc": "Bretter dieser Größe oder größer erfordern eine Zugbestätigung", + "confirmMoves": "Züge bestätigen", + "confirmMovesDesc": "Doppelt tippen, um Züge auf großen Brettern zu bestätigen und Fehlklicks zu vermeiden", + "continue_": "Fortfahren", + "copySGF": "SGF kopieren", + "copyTaskLink": "Aufgabenlink kopieren", + "customExam": "Benutzerdefinierte Prüfung", + "dark": "Dunkel", + "dontShowAgain": "Nicht erneut anzeigen", + "download": "Herunterladen", + "edgeLine": "Randlinie", + "empty": "leer", + "endgameExam": "Endspiel-Prüfung", + "enterTaskLink": "Aufgabenlink eingeben", + "errCannotBeEmpty": "Darf nicht leer sein", + "errFailedToDownloadGame": "Spiel konnte nicht heruntergeladen werden", + "errFailedToLoadGameList": "Spielliste konnte nicht geladen werden. Bitte versuche es erneut.", + "errFailedToUploadGameToAISensei": "Spiel konnte nicht zu AI Sensei hochgeladen werden", + "errIncorrectUsernameOrPassword": "Falscher Benutzername oder falsches Passwort", + "errMustBeAtLeast": "Muss mindestens {n} sein", + "@errMustBeAtLeast": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeAtMost": "Darf höchstens {n} sein", + "@errMustBeAtMost": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeInteger": "Muss eine ganze Zahl sein", + "exit": "Beenden", + "exitTryMode": "Testmodus beenden", + "find": "Suchen", + "findTask": "Problem suchen", + "findTaskByLink": "Mit einem Link", + "findTaskByPattern": "Mit Mustererkennung", + "findTaskResults": "Suchergebnisse", + "findTaskSearching": "Suche...", + "forceCounting": "Auszählen erzwingen", + "foxwqDesc": "Der beliebteste Server in China und weltweit.", + "foxwqName": "Fox Weiqi", + "gameInfo": "Spielinfo", + "gameRecord": "Spielaufzeichnung", + "gradingExam": "Einstufungsprüfung", + "handicap": "Vorgabe", + "help": "Hilfe", + "helpDialogCollections": "Kollektionen sind klassische, kuratierte Sets von hochwertigen Aufgaben, die gemeinsam einen besonderen Wert als Trainingsressource bilden.\n\nDas Hauptziel ist es, eine Kollektion mit einer hohen Erfolgsquote zu lösen. Ein Nebenziel ist es, sie so schnell wie möglich zu lösen.\n\nUm eine Kollektion zu starten oder fortzusetzen, wische im Hochformat nach links über die Kachel der Kollektion oder klicke im Querformat auf die Start-/Weiter-Buttons.", + "helpDialogEndgameExam": "- Endspielprüfungen bestehen aus 10 Endspielaufgaben, und du hast 45 Sekunden pro Aufgabe.\n\n- Du bestehst die Prüfung, wenn du 8 oder mehr Aufgaben korrekt löst (80 % Erfolgsquote).\n\n- Das Bestehen der Prüfung für einen bestimmten Rang schaltet die Prüfung für den nächsten Rang frei.", + "helpDialogGradingExam": "- Einstufungsprüfung bestehen aus 10 Aufgaben, und du hast 45 Sekunden pro Aufgabe.\n\n- Du bestehst die Prüfung, wenn du 8 oder mehr Aufgaben korrekt löst (80 % Erfolgsquote).\n\n- Das Bestehen der Prüfung für einen bestimmten Rang schaltet die Prüfung für den nächsten Rang frei.", + "helpDialogRankedMode": "- Löse Aufgaben ohne Zeitlimit.\n\n- Die Schwierigkeit der Aufgaben steigt entsprechend deiner Lösungs­geschwindigkeit.\n\n- Konzentriere dich darauf, korrekt zu lösen und den höchstmöglichen Rang zu erreichen.", + "helpDialogTimeFrenzy": "- Du hast 3 Minuten Zeit, um so viele Aufgaben wie möglich zu lösen.\n\n- Die Aufgaben werden zunehmend schwieriger, je mehr du löst.\n\n- Wenn du 3 Fehler machst, bist du raus.", + "hideTask": "Aus Fehlern entfernen", + "home": "Startseite", + "komi": "Komi", + "language": "Sprache", + "leave": "Verlassen", + "light": "Hell", + "login": "Anmelden", + "logout": "Abmelden", + "long": "Lang", + "mMinutes": "{m}min", + "@mMinutes": { + "placeholders": { + "m": { + "type": "int" + } + } + }, + "maxNumberOfMistakes": "Maximale Fehleranzahl", + "maxRank": "Max. Rang", + "medium": "Mittel", + "minRank": "Min. Rang", + "minutes": "Minuten", + "month": "Monat", + "msgCannotUseAIRefereeYet": "Der KI-Schiedsrichter kann noch nicht verwendet werden", + "msgCannotUseForcedCountingYet": "Erzwungenes Zählen kann noch nicht verwendet werden", + "msgConfirmDeleteCollectionProgress": "Bist du sicher, dass du den bisherigen Fortschritt löschen möchtest?", + "msgConfirmResignation": "Bist du sicher, dass du aufgeben möchtest?", + "msgConfirmStopEvent": "Bist du sicher, dass du {event} beenden möchtest?", + "@msgConfirmStopEvent": { + "placeholders": { + "event": { + "type": "String" + } + } + }, + "msgDownloadingGame": "Spiel wird heruntergeladen", + "msgGameSavedTo": "Spiel gespeichert unter {path}", + "@msgGameSavedTo": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "msgPleaseWaitForYourTurn": "Bitte warte auf deinen Zug", + "msgSearchingForGame": "Suche nach einem Spiel...", + "msgSgfCopied": "SGF in die Zwischenablage kopiert.", + "msgTaskLinkCopied": "Aufgabenlink kopiert.", + "msgWaitingForOpponentsDecision": "Warte auf die Entscheidung deines Gegners...", + "msgYouCannotPass": "Du kannst nicht passen", + "msgYourOpponentDisagreesWithCountingResult": "Dein Gegner ist mit der Auszählung nicht einverstanden", + "msgYourOpponentRefusesToCount": "Dein Gegner weigert sich zu auszuzählen", + "msgYourOpponentRequestsAutomaticCounting": "Dein Gegner fordert automatisches Auszählen an. Bist du damit einverstanden?", + "myGames": "Meine Spiele", + "myMistakes": "Meine Fehler", + "nTasks": "{count, plural, =0{Keine Aufgaben} =1{1 Aufgabe} other{{count} Aufgaben}}", + "@nTasks": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "nTasksAvailable": "{count, plural, =0{Keine Aufgaben verfügbar} =1{1 Aufgabe verfügbar} other{{count} Aufgaben verfügbar}}", + "@nTasksAvailable": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "newBestResult": "Neuer Rekord!", + "no": "Nein", + "none": "Keine", + "numberOfTasks": "Anzahl der Aufgaben", + "nxnBoardSize": "{n}×{n}", + "@nxnBoardSize": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "ogsDesc": "Ein internationaler Server welcher am meisten in Europa und Amerika genutzt wird.", + "ogsName": "Online Go Server", + "ok": "OK", + "pass": "Passen", + "password": "Passwort", + "play": "Spielen", + "pleaseMarkDeadStones": "Bitte markiere die toten Steine.", + "promotionRequirements": "Anforderungen zum Auftsieg", + "pxsByoyomi": "{p}×{s}s", + "@pxsByoyomi": { + "placeholders": { + "p": { + "type": "int" + }, + "s": { + "type": "int" + } + } + }, + "rank": "Rang", + "rankedMode": "Gewerteter Modus", + "recentRecord": "Letzte Ergebnisse", + "register": "Registrieren", + "rejectDeadStones": "Tote Steine ablehnen", + "resign": "Aufgeben", + "responseDelay": "Antwortverzögerung", + "responseDelayDesc": "Dauer der Verzögerung, bevor beim Lösen einer Aufgabe die Antwort erscheint", + "responseDelayLong": "Lang", + "responseDelayMedium": "Mittel", + "responseDelayNone": "Keine", + "responseDelayShort": "Kurz", + "result": "Ergebnis", + "resultAccept": "Akzeptieren", + "resultReject": "Ablehnen", + "rules": "Regeln", + "rulesChinese": "Chinesisch", + "rulesJapanese": "Japanisch", + "rulesKorean": "Koreanisch", + "sSeconds": "{s}s", + "@sSeconds": { + "placeholders": { + "s": { + "type": "int" + } + } + }, + "save": "Sichern", + "saveSGF": "SGF speichern", + "seconds": "Sekunden", + "settings": "Einstellungen", + "short": "Kurz", + "showCoordinates": "Koordinaten anzeigen", + "showMoveErrorsAsCrosses": "Falsche Züge als Kreuz darstellen", + "showMoveErrorsAsCrossesDesc": "Falsche Züge werden als rotes Kreuz anstatt eines roiten Punkts angezeigt", + "simple": "Einfach", + "sortModeDifficult": "Schwierig", + "sortModeRecent": "Neueste", + "sound": "Ton", + "start": "Start", + "statistics": "Statistiken", + "statsDateColumn": "Datum", + "statsDurationColumn": "Dauer", + "statsTimeColumn": "Zeit", + "stoneShadows": "Steinschatten", + "stones": "Steine", + "subtopic": "Unterthema", + "system": "System", + "task": "Aufgabe", + "taskCorrect": "Richtig", + "taskNext": "Weiter", + "taskNotFound": "Aufgabe nicht gefunden", + "taskRedo": "Erneut versuchen", + "taskSource": "Quelle der Aufgaben", + "taskSourceFromMyMistakes": "Aus meinen Fehlern", + "taskSourceFromTaskTopic": "Aus dem Aufgabenthema", + "taskSourceFromTaskTypes": "Aus den Aufgabentypen", + "taskTag_afterJoseki": "Nach dem joseki", + "taskTag_aiOpening": "KI Eröffnung", + "taskTag_aiVariations": "KI Variationen", + "taskTag_attack": "Angriff", + "taskTag_attackAndDefenseInKo": "Angriff und Verteidigung mit Ko", + "taskTag_attackAndDefenseOfCuts": "Angriff und Verteidigung von Schnitten", + "taskTag_attackAndDefenseOfInvadingStones": "Angriff und Verteidigung von Invasionen", + "taskTag_avoidKo": "Ko vermeiden", + "taskTag_avoidMakingDeadShape": "Tote Form vermeiden", + "taskTag_avoidTrap": "Fallen ausweichen", + "taskTag_basicEndgame": "Endspiel: Grundlagen", + "taskTag_basicLifeAndDeath": "Leben \u0026 Tod: Grundlagen", + "taskTag_basicMoves": "Grundlagen", + "taskTag_basicTesuji": "Tesuji", + "taskTag_beginner": "Beginner", + "taskTag_bend": "Umbiegen", + "taskTag_bentFour": "Toter Winkel", + "taskTag_bentFourInTheCorner": "Toter Winkel in der Ecke", + "taskTag_bentThree": "Gebogene Drei", + "taskTag_bigEyeLiberties": "Freiheiten von großen Augen", + "taskTag_bigEyeVsSmallEye": "Großes Auge vs kleines Auge", + "taskTag_bigPoints": "Große Punkte", + "taskTag_blindSpot": "Blinder Fleck", + "taskTag_breakEye": "Augen verhindern", + "taskTag_breakEyeInOneStep": "Augen verhindern in einem Zug", + "taskTag_breakEyeInSente": "Augen verhindern in Vorhand", + "taskTag_breakOut": "Ausbrechen", + "taskTag_breakPoints": "Punkte verhindern", + "taskTag_breakShape": "Form verhindern", + "taskTag_bridgeUnder": "Untenrum verbinden", + "taskTag_brilliantSequence": "Brillante Sequenz", + "taskTag_bulkyFive": "Klotzige-Fünf", + "taskTag_bump": "Stoßen", + "taskTag_captureBySnapback": "Fang durch Mausefalle", + "taskTag_captureInLadder": "Fang in der Treppe", + "taskTag_captureInOneMove": "Fang in einem Zug", + "taskTag_captureOnTheSide": "Fang an der Seite", + "taskTag_captureToLive": "Fang um zu leben", + "taskTag_captureTwoRecaptureOne": "Zwei fangen, einen zurückgewinnen", + "taskTag_capturingRace": "Freiheitskampf", + "taskTag_capturingTechniques": "Fangtechniken", + "taskTag_carpentersSquareAndSimilar": "Zimmermannswinkel und ähnliches", + "taskTag_chooseTheFight": "Wähle den Kampf", + "taskTag_clamp": "Klemmzug", + "taskTag_clampCapture": "Klemmzug-Fang", + "taskTag_closeInCapture": "Closing-in Fang", + "taskTag_combination": "Kombination", + "taskTag_commonLifeAndDeath": "Leben \u0026 Tod: Häufige Formen", + "taskTag_compareSize": "Größe vergleichen", + "taskTag_compareValue": "Werte vergleichen", + "taskTag_completeKoToSecureEndgameAdvantage": "Ko abschließen, um Vorteil im Endspiel zu sichern", + "taskTag_compositeProblems": "Zusammengesetzte Aufgaben", + "taskTag_comprehensiveTasks": "Umfassende Aufgaben", + "taskTag_connect": "Verbinden", + "taskTag_connectAndDie": "Verbinden und sterben", + "taskTag_connectInOneMove": "Verbinden in einem Zug", + "taskTag_contactFightTesuji": "Nahkampf Tesuji", + "taskTag_contactPlay": "Anlegen", + "taskTag_corner": "Ecke", + "taskTag_cornerIsGoldSideIsSilverCenterIsGrass": "Ecken sind Gold, Seiten sind Silber, das Zentrum ist Gras", + "taskTag_counter": "Wiederlegezug", + "taskTag_counterAttack": "Gegenangriff", + "taskTag_cranesNest": "Kranichnest", + "taskTag_crawl": "Kriechen", + "taskTag_createShortageOfLiberties": "Freiheitsnot schaffen", + "taskTag_crossedFive": "Stern-Fünf", + "taskTag_cut": "Schneiden", + "taskTag_cut2": "Schneiden", + "taskTag_cutAcross": "Schneiden", + "taskTag_defendFromInvasion": "Vor Invasion verteidigen", + "taskTag_defendPoints": "Punkte verteidigen", + "taskTag_defendWeakPoint": "Schwachen Punkt verteidigen", + "taskTag_descent": "Hinabstoßen", + "taskTag_diagonal": "Diagonal", + "taskTag_directionOfCapture": "Richtung des Fangens", + "taskTag_directionOfEscape": "Richtung des Entkommens", + "taskTag_directionOfPlay": "Richtung des Spiels", + "taskTag_doNotUnderestimateOpponent": "Gegner nicht unterschätzen", + "taskTag_doubleAtari": "Doppel Atari", + "taskTag_doubleCapture": "Doppel-Fang", + "taskTag_doubleKo": "Doppel-Ko", + "taskTag_doubleSenteEndgame": "Doppel-Vorhand-Endspiel", + "taskTag_doubleSnapback": "Doppel-Mausefalle", + "taskTag_endgame": "Endspiel: Allgemein", + "taskTag_endgameFundamentals": "Endspielgrundlagen", + "taskTag_endgameIn5x5": "Endspiel auf 5x5", + "taskTag_endgameOn4x4": "Endspiel auf 4x4", + "taskTag_endgameTesuji": "Endspiel Tesuji", + "taskTag_engulfingAtari": "Engulfing Atari", + "taskTag_escape": "Entkommen", + "taskTag_escapeInOneMove": "Entkommen in einem Zug", + "taskTag_exploitShapeWeakness": "Formschwäche ausnutzen", + "taskTag_eyeVsNoEye": "Auge vs kein Auge", + "taskTag_fillNeutralPoints": "Neutrale Punkte füllen", + "taskTag_findTheRoot": "Die Wurzel finden", + "taskTag_firstLineBrilliantMove": "brillante Züge auf der ersten Linie", + "taskTag_flowerSix": "Blumen-Sechs", + "taskTag_goldenChickenStandingOnOneLeg": "Der goldene Hahn steht auf einem Bein", + "taskTag_groupLiberties": "Gruppenfreiheiten", + "taskTag_groupsBase": "Basis der Gruppe", + "taskTag_hane": "Hane", + "taskTag_increaseEyeSpace": "Augenraum vergrößern", + "taskTag_increaseLiberties": "Freiheiten vermehren", + "taskTag_indirectAttack": "Indirekter Angriff", + "taskTag_influenceKeyPoints": "Schlüsselstellen für Einfluss", + "taskTag_insideKill": "Töten von innen", + "taskTag_insideMoves": "Züge im Inneren", + "taskTag_interestingTasks": "Interessante Aufgaben", + "taskTag_internalLibertyShortage": "Interne Freiheitsnot", + "taskTag_invadingTechnique": "Invasionstechnik", + "taskTag_invasion": "Invasion", + "taskTag_jGroupAndSimilar": "J-Gruppe und ähnliche", + "taskTag_josekiFundamentals": "Joseki-Grundlagen", + "taskTag_jump": "Springen", + "taskTag_keepSente": "Vorhand behalten", + "taskTag_killAfterCapture": "Töten nach dem Fang", + "taskTag_killByEyePointPlacement": "Töten durch Platzierung des Augenpunkts", + "taskTag_knightsMove": "Rösselsprung", + "taskTag_ko": "Ko", + "taskTag_kosumiWedge": "Kosumi Keil", + "taskTag_largeKnightsMove": "Großer Rösselsprung", + "taskTag_largeMoyoFight": "Großer Moyo-Kampf", + "taskTag_lifeAndDeath": "Leben \u0026 Tod: Allgemein", + "taskTag_lifeAndDeathOn4x4": "Leben und Tod auf 4x4", + "taskTag_lookForLeverage": "Nach Hebelwirkung suchen", + "taskTag_looseLadder": "Lose Treppe", + "taskTag_lovesickCut": "Liebeskummer Schnitt", + "taskTag_makeEye": "Augen machen", + "taskTag_makeEyeInOneStep": "Augen in einem Zug machen", + "taskTag_makeEyeInSente": "Augen in Vorhand machen", + "taskTag_makeKo": "Ko machen", + "taskTag_makeShape": "Form machen", + "taskTag_middlegame": "Mittelspiel", + "taskTag_monkeyClimbingMountain": "Affe klettert den Berg", + "taskTag_mouseStealingOil": "Maus stiehlt Öl", + "taskTag_moveOut": "Entkommen", + "taskTag_moveTowardsEmptySpace": "In Richtung freies Gebiet laufen", + "taskTag_multipleBrilliantMoves": "Mehrere brillante Züge", + "taskTag_net": "Netz", + "taskTag_netCapture": "Netzfang", + "taskTag_observeSubtleDifference": "Subtile Unterschiede beobachten", + "taskTag_occupyEncloseAndApproachCorner": "Besetzen, umschließen und Ecken annähern", + "taskTag_oneStoneTwoPurposes": "Ein Stein, zwei Zwecke", + "taskTag_opening": "Eröffnung", + "taskTag_openingChoice": "Eröffnungswahl", + "taskTag_openingFundamentals": "Eröffnungsgrundlagen", + "taskTag_orderOfEndgameMoves": "Reihenfolge der Endspielzüge", + "taskTag_orderOfMoves": "Reihenfolge der Züge", + "taskTag_orderOfMovesInKo": "ZUgreihenfolge im Ko", + "taskTag_orioleCapturesButterfly": "Pirol fängt den Schmetterling", + "taskTag_pincer": "Klemmzug", + "taskTag_placement": "Oki (Platzierung)", + "taskTag_plunderingTechnique": "Plundering Technik", + "taskTag_preventBambooJoint": "Bambus-Verbindung verhindern", + "taskTag_preventBridgingUnder": "Unterverbinden verhindern", + "taskTag_preventOpponentFromApproaching": "Annähern verhindern", + "taskTag_probe": "Testzug", + "taskTag_profitInSente": "In Vorhand profitieren", + "taskTag_profitUsingLifeAndDeath": "Vorteil durch Leben und Tod", + "taskTag_push": "Oshi (Schieben)", + "taskTag_pyramidFour": "Vierer-Pyramide", + "taskTag_realEyeAndFalseEye": "Echtes Auge vs falsches Auge", + "taskTag_rectangularSix": "Rechteckige Sechs", + "taskTag_reduceEyeSpace": "Augenraum reduzieren", + "taskTag_reduceLiberties": "Freiheiten reduzieren", + "taskTag_reduction": "Reduktion", + "taskTag_runWeakGroup": "Schwache Gruppe laufen", + "taskTag_sabakiAndUtilizingInfluence": "Sabaki und Einfluss nutzen", + "taskTag_sacrifice": "Opfer", + "taskTag_sacrificeAndSqueeze": "Opfer und Auspressen", + "taskTag_sealIn": "Einschließen", + "taskTag_secondLine": "Zweite Linie", + "taskTag_seizeTheOpportunity": "Die Gelegenheit ergreifen", + "taskTag_seki": "Seki", + "taskTag_senteAndGote": "Sente und Gote", + "taskTag_settleShape": "Form festlegen", + "taskTag_settleShapeInSente": "Form in Vorhand festlegen", + "taskTag_shape": "Form", + "taskTag_shapesVitalPoint": "Vitaler Punkt der Form", + "taskTag_side": "Seite", + "taskTag_smallBoardEndgame": "Ensdspiel auf kleinem Brett", + "taskTag_snapback": "Mausefalle", + "taskTag_solidConnection": "Feste Verbindung", + "taskTag_solidExtension": "Feste Erweiterung", + "taskTag_splitInOneMove": "In einem Zug teilen", + "taskTag_splittingMove": "Teilungszug", + "taskTag_squareFour": "Klotzige-Vier", + "taskTag_squeeze": "Auspressen", + "taskTag_standardCapturingRaces": "Standard Freiheitskämpfe", + "taskTag_standardCornerAndSideEndgame": "Standard Ecken- und Seiten-Endspiel", + "taskTag_straightFour": "Gerader Vierer", + "taskTag_straightThree": "Gerader Dreier", + "taskTag_surroundTerritory": "Territorium umschließen", + "taskTag_symmetricShape": "Symmetrische Form", + "taskTag_techniqueForReinforcingGroups": "Technik zur Verstärkung von Gruppen", + "taskTag_techniqueForSecuringTerritory": "Technik zur Sicherung des Territoriums", + "taskTag_textbookTasks": "Textbuch-Aufgaben", + "taskTag_thirdAndFourthLine": "Dritte und vierte Linie", + "taskTag_threeEyesTwoActions": "Drei Augen, zwei Aktionen", + "taskTag_threeSpaceExtensionFromTwoStones": "Drei-Raum-Erweiterung von zwei Steinen", + "taskTag_throwIn": "Einwerfen", + "taskTag_tigersMouth": "Tigerrachen", + "taskTag_tombstoneSqueeze": "Grabstein Auspressen", + "taskTag_tripodGroupWithExtraLegAndSimilar": "Tripod-Gruppe mit zusätzlichem Bein und ähnlichem", + "taskTag_twoHaneGainOneLiberty": "Doppel-Hane gewinnt eine Freiheit", + "taskTag_twoHeadedDragon": "Zweiköpfiger Drache", + "taskTag_twoSpaceExtension": "Zwei-Raum-Erweiterung", + "taskTag_typesOfKo": "Arten von Ko", + "taskTag_underTheStones": "Unter den Steinen", + "taskTag_underneathAttachment": "Shitatsuke (unterhalb Anlegen)", + "taskTag_urgentPointOfAFight": "Dringender Punkt eines Kampfes", + "taskTag_urgentPoints": "Dringende Punkte", + "taskTag_useConnectAndDie": "Verbinden und sterben", + "taskTag_useCornerSpecialProperties": "Spezielle Eigenschaften der Ecke nutzen", + "taskTag_useDescentToFirstLine": "Abstieg zur ersten Linie nutzen", + "taskTag_useInfluence": "Einfluss nutzen", + "taskTag_useOpponentsLifeAndDeath": "Das Leben und Tod des Gegners nutzen", + "taskTag_useShortageOfLiberties": "Mangel an Freiheiten nutzen", + "taskTag_useSnapback": "Mausefalle nutzen", + "taskTag_useSurroundingStones": "Umgebende Steine nutzen", + "taskTag_vitalAndUselessStones": "Vitale und nutzlose Steine", + "taskTag_vitalPointForBothSides": "Vitaler Punkt für beide Seiten", + "taskTag_vitalPointForCapturingRace": "Vitaler Punkt für den Freiheitskampf", + "taskTag_vitalPointForIncreasingLiberties": "Vitaler Punkt zur Erhöhung der Freiheiten", + "taskTag_vitalPointForKill": "Vitaler Punkt für das Töten", + "taskTag_vitalPointForLife": "Vitaler Punkt für das Leben", + "taskTag_vitalPointForReducingLiberties": "Vitaler Punkt zur Verringerung der Freiheiten", + "taskTag_wedge": "Warikomi (Keil)", + "taskTag_wedgingCapture": "Warikomi-Fang", + "taskTimeout": "Zeitüberschreitung", + "taskTypeAppreciation": "Wertschätzung", + "taskTypeCapture": "Steine fangen", + "taskTypeCaptureRace": "Freiheitskampf", + "taskTypeEndgame": "Endspiel", + "taskTypeJoseki": "Joseki", + "taskTypeLifeAndDeath": "Leben \u0026 Tod", + "taskTypeMiddlegame": "Mittelspiel", + "taskTypeOpening": "Eröffnung", + "taskTypeTesuji": "Tesuji", + "taskTypeTheory": "Theorie", + "taskWrong": "Falsch", + "tasksSolved": "Gelöste Aufgaben", + "test": "Test", + "theme": "Thema", + "thick": "Dick", + "timeFrenzy": "Zeitrausch", + "timeFrenzyMistakes": "Zeitrausch Fehler", + "timeFrenzyMistakesDesc": "Fehler im Zeitrausch Modus werden gespeichert", + "randomizeTaskOrientation" : "Zufällige Aufgabenorientierung", + "randomizeTaskOrientationDesc" : "Zufälliges Drehen und Spiegeln von Aufgaben entlang horizontaler, vertikaler und diagonaler Achsen, um Auswendiglernen zu verhindern und die Mustererkennung zu verbessern.", + "timePerTask": "Zeit pro Aufgabe", + "today": "Heute", + "tooltipAnalyzeWithAISensei": "Mit AI Sensei analysieren", + "tooltipDownloadGame": "Spiel herunterladen", + "topic": "Thema", + "topicExam": "Themen Prüfung", + "topics": "Themen", + "train": "Trainieren", + "trainingAvgTimePerTask": "Durchschn. Zeit pro Aufgabe", + "trainingFailed": "Nicht bestanden", + "trainingMistakes": "Fehler", + "trainingPassed": "Bestanden", + "trainingTotalTime": "Gesamtzeit", + "tryCustomMoves": "Eigene Züge ausprobieren", + "tygemDesc": "Der beliebteste Server in Korea und einer der beliebtesten weltweit.", + "tygemName": "Tygem Baduk", + "type": "Typ", + "ui": "UI", + "userInfo": "Benutzerinfo", + "username": "Benutzername", + "voice": "Stimme", + "week": "Woche", + "white": "Weiß", + "yes": "Ja" +} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 00000000..f0595213 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,518 @@ +{ + "about": "About", + "acceptDeadStones": "Accept dead stones", + "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", + "clearBoard": "Clear", + "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", + "copySGF": "Copy SGF", + "copyTaskLink": "Copy task link", + "customExam": "Custom exam", + "dark": "Dark", + "dontShowAgain": "Don't show again", + "download": "Download", + "edgeLine": "Edge line", + "empty": "Empty", + "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", + "findTaskByLink": "By link", + "findTaskByPattern": "By pattern", + "findTaskResults": "Search results", + "findTaskSearching": "Searching...", + "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.", + "hideTask": "Remove from mistakes", + "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...", + "msgSgfCopied": "SGF copied to clipboard", + "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": "An international server, most popular in Europe and the Americas.", + "ogsName": "Online Go Server", + "ok": "OK", + "pass": "Pass", + "password": "Password", + "play": "Play", + "pleaseMarkDeadStones": "Please mark the dead stones.", + "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", + "rejectDeadStones": "Reject dead stones", + "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", + "resultAccept": "Accept", + "resultReject": "Reject", + "rules": "Rules", + "rulesChinese": "Chinese", + "rulesJapanese": "Japanese", + "rulesKorean": "Korean", + "sSeconds": "{s}s", + "@sSeconds": { + "placeholders": { + "s": { + "type": "int" + } + } + }, + "save": "Save", + "saveSGF": "Save SGF", + "seconds": "Seconds", + "settings": "Settings", + "short": "Short", + "showCoordinates": "Show coordinates", + "showMoveErrorsAsCrosses": "Display wrong moves as crosses", + "showMoveErrorsAsCrossesDesc": "Display wrong moves as red crosses instead of red dots", + "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", + "taskNotFound": "Task not found", + "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", + "timeFrenzyMistakes": "Track Time Frenzy mistakes", + "timeFrenzyMistakesDesc": "Enable to save mistakes made in Time Frenzy", + "randomizeTaskOrientation" : "Randomize task orientation", + "randomizeTaskOrientationDesc" : "Randomly rotates and reflects tasks along horizontal, vertical, and diagonal axes to prevent memorization and enhance pattern recognition.", + "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..be0c2353 --- /dev/null +++ b/lib/l10n/app_es.arb @@ -0,0 +1,518 @@ +{ + "about": "Acerca de WeiqiHub", + "acceptDeadStones": "Aceptar piedras muertas", + "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", + "clearBoard": "Limpiar", + "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", + "copySGF": "Copiar SGF", + "copyTaskLink": "Copiar enlace al problema", + "customExam": "Examen personalizado", + "dark": "Oscuro", + "dontShowAgain": "No volver a mostrar", + "download": "Descargar", + "edgeLine": "Línea de borde", + "empty": "Vacío", + "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", + "findTaskByLink": "Con enlace", + "findTaskByPattern": "Con patrón", + "findTaskResults": "Resultados de búsqueda", + "findTaskSearching": "Buscando...", + "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.", + "hideTask": "Quitar de errores", + "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áx.", + "medium": "Media", + "minRank": "Rango mín.", + "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...", + "msgSgfCopied": "SGF copiado al portapapeles", + "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": "Un servidor internacional, más popular en Europa y las Américas.", + "ogsName": "Online Go Server", + "ok": "OK", + "pass": "Pasar", + "password": "Contraseña", + "play": "Jugar", + "pleaseMarkDeadStones": "Por favor, marca las piedras muertas.", + "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", + "rejectDeadStones": "Rechazar piedras muertas", + "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", + "resultAccept": "Aceptar", + "resultReject": "Rechazar", + "rules": "Reglas", + "rulesChinese": "Chinas", + "rulesJapanese": "Japonesas", + "rulesKorean": "Coreanas", + "sSeconds": "{s}s", + "@sSeconds": { + "placeholders": { + "s": { + "type": "int" + } + } + }, + "save": "Guardar", + "saveSGF": "Guardar SGF", + "seconds": "Segundos", + "settings": "Preferencias", + "short": "Corta", + "showCoordinates": "Mostrar coordenadas", + "showMoveErrorsAsCrosses": "Mostrar jugadas incorrectas como cruces", + "showMoveErrorsAsCrossesDesc": "Mostrar jugadas incorrectas como cruces rojas en lugar de puntos rojos", + "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", + "taskNotFound": "No se encontró el problema", + "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": "Escapar", + "taskTag_breakPoints": "Destruir puntos", + "taskTag_breakShape": "Destruir forma", + "taskTag_bridgeUnder": "Conección submarina", + "taskTag_brilliantSequence": "Secuencia brillante", + "taskTag_bulkyFive": "Auto", + "taskTag_bump": "Golpe", + "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": "Captura de cierre", + "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": "Arrastre", + "taskTag_createShortageOfLiberties": "Crear falta de libertades", + "taskTag_crossedFive": "Cruz", + "taskTag_cut": "Corte", + "taskTag_cut2": "Corte", + "taskTag_cutAcross": "Corte transversal", + "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": "Atari de envoltura", + "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": "Encuentra la raíz", + "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": "Mata con jugada interna", + "taskTag_insideMoves": "Jugadas internas", + "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": "Hocico de gran cerdo y similares", + "taskTag_josekiFundamentals": "Fundamentos de joseki", + "taskTag_jump": "Salto", + "taskTag_keepSente": "Mantener sente", + "taskTag_killAfterCapture": "Matar mediante captura", + "taskTag_killByEyePointPlacement": "Mata con jugada en ojo interno", + "taskTag_knightsMove": "Salto de caballo", + "taskTag_ko": "Ko", + "taskTag_kosumiWedge": "Cuña diagonal", + "taskTag_largeKnightsMove": "Salto grande de caballo", + "taskTag_largeMoyoFight": "Peleas de moyo de larga escala", + "taskTag_lifeAndDeath": "Vida y muerte: general", + "taskTag_lifeAndDeathOn4x4": "Vida y muerte en 4x4", + "taskTag_lookForLeverage": "Busca palanca", + "taskTag_looseLadder": "Escalera larga", + "taskTag_lovesickCut": "Corte del amor", + "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": "El mono escala la montaña", + "taskTag_mouseStealingOil": "El ratón roba aceite", + "taskTag_moveOut": "Moverse al exterior", + "taskTag_moveTowardsEmptySpace": "Moverse hacia el espacio vacío", + "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": "El turpial captura a la mariposa", + "taskTag_pincer": "Pinza", + "taskTag_placement": "Jugada de colocación", + "taskTag_plunderingTechnique": "Técnica de saqueo", + "taskTag_preventBambooJoint": "Prevén la conección de bambú", + "taskTag_preventBridgingUnder": "Prevén la conección submarina", + "taskTag_preventOpponentFromApproaching": "Prevén que el oponente se acerque", + "taskTag_probe": "Jugada de prueba", + "taskTag_profitInSente": "Ganancia en sente", + "taskTag_profitUsingLifeAndDeath": "Ganancia utilizando vida y muerte", + "taskTag_push": "Empuje", + "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": "Extensión de tres puntos desde dos piedras", + "taskTag_throwIn": "Sacrificio interno", + "taskTag_tigersMouth": "Boca de tigre", + "taskTag_tombstoneSqueeze": "Fantasma cabezón", + "taskTag_tripodGroupWithExtraLegAndSimilar": "Hocico de cerdito y similares", + "taskTag_twoHaneGainOneLiberty": "Gana una libertad con doble hane", + "taskTag_twoHeadedDragon": "Dragón de dos cabezas", + "taskTag_twoSpaceExtension": "Extensión de dos puntos", + "taskTag_typesOfKo": "Tipos de ko", + "taskTag_underTheStones": "Sacrificio y contracaptura", + "taskTag_underneathAttachment": "Apego submarino", + "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", + "timeFrenzyMistakes": "Rastrear errores en Contrarreloj", + "timeFrenzyMistakesDesc": "Habilitar para guardar errores cometidos en Contrarreloj", + "randomizeTaskOrientation": "Orientación aleatoria de problemas", + "randomizeTaskOrientationDesc": "Rota y refleja aleatoriamente los problemas a lo largo de los ejes horizontal, vertical y diagonal para evitar la memorización y mejorar el reconocimiento de patrones.", + "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_it.arb b/lib/l10n/app_it.arb new file mode 100644 index 00000000..065853df --- /dev/null +++ b/lib/l10n/app_it.arb @@ -0,0 +1,511 @@ +{ + "about": "Informazioni", + "acceptDeadStones": "Accetta pietre catturate", + "accuracy": "Precisione", + "aiReferee": "Arbitro AI", + "aiSensei": "AI Sensei", + "alwaysBlackToPlay": "Black to play!", + "alwaysBlackToPlayDesc": "Impone la prima mossa al Nero per evitare confusione nei problemi", + "appearance": "Aspetto", + "autoCounting": "Conteggio automatico", + "autoMatch": "Abbinamento automatico", + "behaviour": "Preferenze", + "bestResult": "Record", + "black": "Nero", + "board": "Goban", + "boardSize": "Dimensioni", + "boardTheme": "Stile", + "byRank": "Per livello", + "cancel": "Annulla", + "captures": "Prigionieri", + "clearBoard": "Svuota", + "collectStats": "Registra statistiche", + "collections": "Raccolte", + "confirm": "Conferma", + "confirmBoardSize": "Conferma in base alla dimensione del goban", + "confirmBoardSizeDesc": "Per goban di queste dimensioni o maggiori, chiedi di confermare la mossa con un doppio tocco", + "confirmMoves": "Conferma la mossa", + "confirmMovesDesc": "Richiedi il doppio tocco per confermare la mossa sui goban più grandi (evita misclick)", + "continue_": "Continua", + "copySGF": "Copia SGF", + "copyTaskLink": "Copia link", + "customExam": "Esame personalizzato", + "dark": "Scuro", + "dontShowAgain": "Non mostrare più", + "download": "Download", + "edgeLine": "Bordo", + "empty": "Vuoto", + "endgameExam": "Esame sul fine gioco", + "enterTaskLink": "Inserisci il link", + "errCannotBeEmpty": "Non può essere vuoto", + "errFailedToDownloadGame": "Scaricamento partita fallito", + "errFailedToLoadGameList": "Impossibile caricare l'elenco delle partite. Per favore riprova più tardi.", + "errFailedToUploadGameToAISensei": "Impossibile caricare la partita su AI Sensei", + "errIncorrectUsernameOrPassword": "Username o password non corretti", + "errMustBeAtLeast": "Deve essere almeno {n}", + "@errMustBeAtLeast": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeAtMost": "Deve essere al massimo {n}", + "@errMustBeAtMost": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeInteger": "Deve essere un numero intero", + "exit": "Esci", + "exitTryMode": "Ritorna", + "find": "Trova", + "findTask": "Trova problema", + "findTaskByLink": "Per link", + "findTaskByPattern": "Per posizione", + "findTaskResults": "Risultati", + "findTaskSearching": "Ricerca in corso...", + "forceCounting": "Forza conteggio", + "foxwqDesc": "Il server più popolare in Cina e nel mondo.", + "foxwqName": "Fox Weiqi", + "gameInfo": "Info partita", + "gameRecord": "Risultato partita", + "gradingExam": "Esame di livello", + "handicap": "Handicap", + "help": "Aiuto", + "helpDialogCollections": "Le raccolte sono insiemi classici e curati di problemi di alta qualità che hanno un valore speciale come risorsa formativa. L'obiettivo principale è risolvere una raccolta con un alto tasso di successo. Un obiettivo secondario è risolverla il più velocemente possibile. Per iniziare o continuare a risolvere una raccolta di problemi, scorri verso sinistra sul riquadro della raccolta in modalità verticale o fai clic sui pulsanti Inizia/Continua in modalità orizzontale.", + "helpDialogEndgameExam": "- L'esame sul fine gioco contiene 10 problemi di fine gioco. Hai 45 secondi di tempo per risolvere ogni problema.\n\n- Superi la prova se risolvi correttamente almeno 8 problemi (tasso di successo: 80%).\n\n- Superare un livello sblocca l'esame per il livello successivo.", + "helpDialogGradingExam": "- L'esame di livello contiene 10 problemi. Hai 45 secondi di tempo per risolvere ogni problema.\n\n- Superi la prova se risolvi correttamente almeno 8 problemi (tasso di successo: 80%).\n\n- Superare un livello sblocca l'esame per il livello successivo.", + "helpDialogRankedMode": "- Risolvi i problemi senza limiti di tempo.\n\n- La difficoltà aumenta in base alla tua rapidità.\n\n- Impegnati a risolverli correttamente e raggiungi il grado più alto possibile.", + "helpDialogTimeFrenzy": "- Risolvi il maggior numero di problemi possibile in 3 minuti.\n\n- I problemi diventano via via più difficili.\n\n- Se fai 3 errori, sei fuori.", + "hideTask": "Rimuovi dagli errori", + "home": "Home", + "komi": "Komi", + "language": "Lingua", + "leave": "Abbandona", + "light": "Chiaro", + "login": "Login", + "logout": "Logout", + "long": "Lungo", + "mMinutes": "{m}min", + "@mMinutes": { + "placeholders": { + "m": { + "type": "int" + } + } + }, + "maxNumberOfMistakes": "Numero massimo di errori", + "maxRank": "Livello massimo", + "medium": "Medio", + "minRank": "Livello minimo", + "minutes": "Minuti", + "month": "Mese", + "msgCannotUseAIRefereeYet": "È troppo presto per ricorrere all'arbitro AI", + "msgCannotUseForcedCountingYet": "È troppo presto per forzare il conteggio", + "msgConfirmDeleteCollectionProgress": "Vuoi davvero cancellare il precedente tentativo?", + "msgConfirmResignation": "Vuoi davvero abbandonare?", + "msgConfirmStopEvent": "Vuoi interrompere la prova?", + "msgDownloadingGame": "Scaricamento partita", + "msgGameSavedTo": "Partita salvata in {path}", + "@msgGameSavedTo": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "msgPleaseWaitForYourTurn": "Per favore, attendi il tuo turno", + "msgSearchingForGame": "Cerco una partita...", + "msgSgfCopied": "SGF copiato", + "msgTaskLinkCopied": "Link copiato", + "msgWaitingForOpponentsDecision": "In attesa che l'avversario decida...", + "msgYouCannotPass": "Non puoi passare", + "msgYourOpponentDisagreesWithCountingResult": "L'avversario non è d'accordo col risultato", + "msgYourOpponentRefusesToCount": "L'avversario ha rifiutato il conteggio", + "msgYourOpponentRequestsAutomaticCounting": "L'avversario ha richiesto il conteggio automatico. Sei d'accordo?", + "myGames": "Le mie partite", + "myMistakes": "I miei errori", + "nTasks": "{count, plural, =0{Nessun problema} =1{1 problema} other{{count} problemi}}", + "@nTasks": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "nTasksAvailable": "{count, plural, =0{Problemi non disponibili} =1{1 problema disponibile} other{{count} problemi disponibili}}", + "@nTasksAvailable": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "newBestResult": "Nuovo record!", + "no": "No", + "none": "Nessuna", + "numberOfTasks": "Numero di problemi", + "nxnBoardSize": "{n}×{n}", + "@nxnBoardSize": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "ogsDesc": "Un server internazionale, più popolare in Europa e nelle Americhe.", + "ogsName": "Online Go Server", + "ok": "OK", + "pass": "Passa", + "password": "Password", + "play": "Gioca", + "pleaseMarkDeadStones": "Indica le pietre catturate.", + "promotionRequirements": "Requisiti per la promozione", + "pxsByoyomi": "{p}×{s}s", + "@pxsByoyomi": { + "placeholders": { + "p": { + "type": "int" + }, + "s": { + "type": "int" + } + } + }, + "randomizeTaskOrientation" : "Orientamento casuale", + "randomizeTaskOrientationDesc" : "Ruota e rifletti casualmente i problemi lungo gli assi orizzontale, verticale e diagonale per prevenire la memorizzazione e migliorare il riconoscimento dei pattern.", + "rank": "Livello", + "rankedMode": "Modalità classificata", + "recentRecord": "Risultato recente", + "register": "Registrati", + "rejectDeadStones": "Rifiuta pietre catturate", + "resign": "Abbandona", + "responseDelay": "Ritarda la risposta", + "responseDelayDesc": "Imposta un tempo di attesa prima di mostrare la risposta di un problema", + "responseDelayLong": "Lungo", + "responseDelayMedium": "Medio", + "responseDelayNone": "Nessuno", + "responseDelayShort": "Breve", + "result": "Risultato", + "resultAccept": "Accetta", + "resultReject": "Rifiuta", + "rules": "Regole", + "rulesChinese": "Cinesi", + "rulesJapanese": "Giapponesi", + "rulesKorean": "Coreane", + "sSeconds": "{s}s", + "@sSeconds": { + "placeholders": { + "s": { + "type": "int" + } + } + }, + "save": "Salva", + "saveSGF": "Salva SGF", + "seconds": "Secondi", + "settings": "Impostazioni", + "short": "Corta", + "showCoordinates": "Coordinate", + "showMoveErrorsAsCrosses" : "Usa le croci per le mosse sbagliate", + "showMoveErrorsAsCrossesDesc": "Indica le mosse sbagliate con una X rossa invece di un pallino", + "simple": "Sottile", + "sortModeDifficult": "Difficile", + "sortModeRecent": "Recente", + "sound": "Suono", + "start": "Inizia", + "statistics": "Statistiche", + "statsDateColumn": "Data", + "statsDurationColumn": "Durata", + "statsTimeColumn": "Tempo", + "stoneShadows": "Ombre", + "stones": "Pietre", + "subtopic": "Sottoargomento", + "system": "Sistema", + "task": "Problema", + "taskCorrect": "Corretto", + "taskNext": "Prossimo", + "taskNotFound": "Problema non trovato", + "taskRedo": "Ritenta", + "taskSource": "Fonte dei problemi", + "taskSourceFromMyMistakes": "I miei errori", + "taskSourceFromTaskTopic": "Argomento", + "taskSourceFromTaskTypes": "Tipologia", + "taskTag_afterJoseki":"Dopo il joseki", + "taskTag_aiOpening":"Aperture AI", + "taskTag_aiVariations":"Varianti AI", + "taskTag_attack":"Attaccare", + "taskTag_attackAndDefenseInKo":"Attacco e difesa nel ko", + "taskTag_attackAndDefenseOfCuts":"Attacco e difesa dei tagli", + "taskTag_attackAndDefenseOfInvadingStones":"Attacco e difesa nelle invasioni", + "taskTag_avoidKo":"Evita il ko", + "taskTag_avoidMakingDeadShape":"Evita di creare una forma morta", + "taskTag_avoidTrap":"Evita le trappole", + "taskTag_basicMoves":"Movimenti di base", + "taskTag_beginner":"Principiante", + "taskTag_bend":"Piega (bend)", + "taskTag_bentFour":"Quattro piegato (bent four)", + "taskTag_bentFourInTheCorner":"Quattro piegato nell'angolo", + "taskTag_bentThree":"Tre piegato (bent three)", + "taskTag_bigEyeVsSmallEye":"Occhio grande vs. occhio piccolo", + "taskTag_bigEyeLiberties":"Libertà degli occhi grandi", + "taskTag_bigPoints":"Punti grandi", + "taskTag_blindSpot":"Punto cieco", + "taskTag_breakEye":"Distruggi l'occhio", + "taskTag_breakEyeInOneStep":"Distruggi l'occhio in un passo", + "taskTag_breakEyeInSente":"Distruggi l'occhio in sente", + "taskTag_breakOut":"Evadi", + "taskTag_breakPoints":"Togli punti", + "taskTag_breakShape":"Rovina la forma", + "taskTag_bridgeUnder":"Ponte (watari)", + "taskTag_brilliantSequence":"Sequenze brillanti", + "taskTag_bulkyFive":"Cinque a manico di coltello (bulky five)", + "taskTag_bump":"Bump", + "taskTag_captureBySnapback":"Cattura con snapback", + "taskTag_captureInLadder":"Cattura in scala", + "taskTag_captureInOneMove":"Cattura in una mossa", + "taskTag_captureOnTheSide":"Cattura sul lato", + "taskTag_captureToLive":"Cattura per vivere", + "taskTag_captureTwoRecaptureOne":"Cattura due, ricattura una", + "taskTag_capturingRace":"Semeai (capturing race)", + "taskTag_capturingTechniques":"Tecniche di cattura", + "taskTag_carpentersSquareAndSimilar":"Quadrato del carpentiere e simili", + "taskTag_chooseTheFight":"Scegli il combattimento", + "taskTag_clamp":"Morsa (clamp)", + "taskTag_clampCapture":"Cattura con la morsa", + "taskTag_closeInCapture":"Cattura progressiva", + "taskTag_combination":"Combinazione", + "taskTag_compareSize":"Confronta la dimensione", + "taskTag_compareValue":"Confronta il valore", + "taskTag_completeKoToSecureEndgameAdvantage":"Completa il ko nel fine gioco", + "taskTag_compositeProblems":"Problemi compositi", + "taskTag_comprehensiveTasks":"Problemi completi", + "taskTag_connect":"Connetti", + "taskTag_connectAndDie":"Connetti e muori (oiotoshi)", + "taskTag_connectInOneMove":"Connetti in una mossa", + "taskTag_contactFightTesuji":"Tesuji di combattimento a contatto", + "taskTag_contactPlay":"Giocare a contatto (tsuke)", + "taskTag_corner":"Angolo", + "taskTag_cornerIsGoldSideIsSilverCenterIsGrass":"L'angolo è oro, il lato è argento, il centro è erba", + "taskTag_counter":"Resisti", + "taskTag_counterAttack":"Contrattacca", + "taskTag_cranesNest":"Nido di gru", + "taskTag_crawl":"Striscia in seconda linea (crawl)", + "taskTag_createShortageOfLiberties":"Crea mancanza di libertà", + "taskTag_crossedFive":"Cinque a croce (crossed five)", + "taskTag_cut":"Taglio", + "taskTag_cut2":"Taglio", + "taskTag_cutAcross":"Taglia attraverso il keima", + "taskTag_defendFromInvasion":"Difenditi da un'invasione", + "taskTag_defendPoints":"Difendi il punteggio", + "taskTag_defendWeakPoint":"Difendi i punti deboli", + "taskTag_descent":"Discesa", + "taskTag_diagonal":"Diagonale", + "taskTag_directionOfCapture":"Direzione di cattura", + "taskTag_directionOfEscape":"Direzione di fuga", + "taskTag_directionOfPlay":"Direzione di gioco", + "taskTag_doNotUnderestimateOpponent":"Non sottostimare l'avversario", + "taskTag_doubleAtari":"Doppio atari", + "taskTag_doubleCapture":"Doppia cattura", + "taskTag_twoHaneGainOneLiberty":"Il doppio hane aggiunge una libertà", + "taskTag_doubleKo":"Doppio ko", + "taskTag_doubleSenteEndgame":"Fine gioco in doppio sente", + "taskTag_doubleSnapback":"Doppio snapback", + "taskTag_endgameFundamentals":"Fondamenti di fine gioco", + "taskTag_endgameOn4x4":"Fine gioco sul 4x4", + "taskTag_endgameIn5x5":"Fine gioco sul 5x5", + "taskTag_endgameTesuji":"Tesuji di fine gioco", + "taskTag_basicEndgame":"Fine gioco: base", + "taskTag_endgame":"Fine gioco: generico", + "taskTag_engulfingAtari":"Atari per accerchiamento", + "taskTag_escape":"Fuggi", + "taskTag_escapeInOneMove":"Fuggi in una mossa", + "taskTag_exploitShapeWeakness":"Sfrutta le debolezze", + "taskTag_eyeVsNoEye":"Meari menashi (eye vs. no-eye)", + "taskTag_fillNeutralPoints":"Riempi i dame", + "taskTag_findTheRoot":"Trova la radice", + "taskTag_firstLineBrilliantMove":"Tesuji in prima linea", + "taskTag_flowerSix":"Sei a grappolo d'uva (rabbity six)", + "taskTag_goldenChickenStandingOnOneLeg":"Il gallo d'oro sta su una zampa", + "taskTag_groupLiberties":"Libertà dei gruppi", + "taskTag_groupsBase":"Base del gruppo", + "taskTag_hane":"Hane", + "taskTag_increaseEyeSpace":"Aumenta lo spazio vitale", + "taskTag_increaseLiberties":"Aumenta le libertà", + "taskTag_indirectAttack":"Attacco indiretto", + "taskTag_influenceKeyPoints":"Punti chiave dell'influenza", + "taskTag_insideKill":"Uccidi dall'interno", + "taskTag_insideMoves":"Mosse interne", + "taskTag_interestingTasks":"Problemi interessanti", + "taskTag_internalLibertyShortage":"Carenza di libertà interne", + "taskTag_invadingTechnique":"Tecniche di invasione", + "taskTag_invasion":"Invasione", + "taskTag_jGroupAndSimilar":"Gruppo J e simili", + "taskTag_josekiFundamentals":"Fondamenti di joseki", + "taskTag_jump":"Salto", + "taskTag_keepSente":"Mantieni il sente", + "taskTag_killAfterCapture":"Uccidi dopo la cattura", + "taskTag_killByEyePointPlacement":"Uccidi giocando nel punto vitale", + "taskTag_knightsMove":"Mossa del cavallo (keima)", + "taskTag_ko":"Ko", + "taskTag_kosumiWedge":"Taglia il kosumi (atekomi)", + "taskTag_largeKnightsMove":"Mossa del cavallo grande (ogeima)", + "taskTag_largeMoyoFight":"Combattimento nel moyo grande", + "taskTag_basicLifeAndDeath":"Vita e morte: base", + "taskTag_commonLifeAndDeath":"Vita e morte: forme tipiche", + "taskTag_lifeAndDeath":"Vita e morte: generale", + "taskTag_lifeAndDeathOn4x4":"Vita e morte sul 4x4", + "taskTag_lookForLeverage":"Sfrutta le forzanti", + "taskTag_looseLadder":"Scala lasca", + "taskTag_lovesickCut":"Taglio degli innamorati", + "taskTag_makeEye":"Fai un occhio", + "taskTag_makeEyeInOneStep":"Fai un occhio in un passo", + "taskTag_makeEyeInSente":"Fai un occhio in sente", + "taskTag_makeKo":"Fai ko", + "taskTag_makeShape":"Fai forma", + "taskTag_middlegame":"Mediogioco (chuban)", + "taskTag_monkeyClimbingMountain":"La scimmia scala la montagna", + "taskTag_mouseStealingOil":"Il topo ruba l'olio", + "taskTag_moveOut":"Scappa", + "taskTag_moveTowardsEmptySpace":"Muovi verso lo spazio vuoto", + "taskTag_multipleBrilliantMoves":"Tesuji multipli", + "taskTag_net":"Rete (geta)", + "taskTag_netCapture":"Cattura con rete", + "taskTag_observeSubtleDifference":"Osserva le piccole differenze", + "taskTag_occupyEncloseAndApproachCorner":"Occupa, circonda e approccia gli angoli", + "taskTag_oneStoneTwoPurposes":"Una pietra, due scopi", + "taskTag_opening":"Apertura (fuseki)", + "taskTag_openingChoice":"Scegli l'apertura", + "taskTag_openingFundamentals":"Fondamenti delle aperture", + "taskTag_orderOfEndgameMoves":"Ordine delle mosse nel fine gioco", + "taskTag_orderOfMoves":"Ordine delle mosse", + "taskTag_orderOfMovesInKo":"Ordine delle mosse nel ko", + "taskTag_orioleCapturesButterfly":"L'oriolo cattura la farfalla", + "taskTag_pincer":"Pinza", + "taskTag_placement":"Oki (placement)", + "taskTag_plunderingTechnique":"Tecniche di saccheggio", + "taskTag_preventBridgingUnder":"Impedisci il ponte (watari)", + "taskTag_preventOpponentFromApproaching":"Previeni l'approccio dell'avversario", + "taskTag_preventBambooJoint":"Previeni il bamboo joint", + "taskTag_probe":"Sonda (probe)", + "taskTag_profitInSente":"Approfitta del sente", + "taskTag_profitUsingLifeAndDeath":"Approfitta della vita-e-morte", + "taskTag_push":"Spinta", + "taskTag_pyramidFour":"Quattro a forma di T", + "taskTag_realEyeAndFalseEye":"Occhio vero vs. occhio falso", + "taskTag_rectangularSix":"Sei a forma di rettangolo", + "taskTag_reduceEyeSpace":"Riduci lo spazio vitale", + "taskTag_reduceLiberties":"Riduci le libertà", + "taskTag_reduction":"Riduzione", + "taskTag_runWeakGroup":"Gestisci il gruppo debole", + "taskTag_sabakiAndUtilizingInfluence":"Sabaki e utilizzo dell'influenza", + "taskTag_sacrifice":"Sacrificio", + "taskTag_sacrificeAndSqueeze":"Sacrificio e squeeze", + "taskTag_sealIn":"Sigilla", + "taskTag_secondLine":"Seconda linea", + "taskTag_seizeTheOpportunity":"Cogli l'attimo", + "taskTag_seki":"Seki", + "taskTag_senteAndGote":"Sente e gote", + "taskTag_settleShape":"Stabilizza la forma", + "taskTag_settleShapeInSente":"Stabilizza la forma in sente", + "taskTag_shape":"Forma", + "taskTag_shapesVitalPoint":"Punto vitale della forma", + "taskTag_side":"Lato", + "taskTag_smallBoardEndgame":"Fine gioco su goban piccolo", + "taskTag_snapback":"Snapback", + "taskTag_solidConnection":"Connessione solida", + "taskTag_solidExtension":"Estensione solida", + "taskTag_splitInOneMove":"Separa in una mossa", + "taskTag_splittingMove":"Mosse per separare", + "taskTag_squareFour":"Quattro a forma di quadrato", + "taskTag_squeeze":"Squeeze", + "taskTag_standardCapturingRaces":"Semeai standard", + "taskTag_standardCornerAndSideEndgame":"Fine gioco standard (angoli e lati)", + "taskTag_straightFour":"Quattro in fila", + "taskTag_straightThree":"Tre in fila", + "taskTag_surroundTerritory":"Circonda il territorio", + "taskTag_symmetricShape":"Forme simmetriche", + "taskTag_techniqueForReinforcingGroups":"Tecniche per rafforzare i gruppi", + "taskTag_techniqueForSecuringTerritory":"Tecniche per mettere in sicurezza un territorio", + "taskTag_basicTesuji":"Tesuji", + "taskTag_textbookTasks":"Problemi da libro", + "taskTag_thirdAndFourthLine":"Terza e quarta linea", + "taskTag_threeEyesTwoActions":"Tre occhi, due azioni", + "taskTag_threeSpaceExtensionFromTwoStones":"Estensione di tre spazi da due pietre", + "taskTag_throwIn":"Throw-in", + "taskTag_tigersMouth":"Bocca di tigre", + "taskTag_tombstoneSqueeze":"Tombstone squeeze (Pagoda squeeze)", + "taskTag_tripodGroupWithExtraLegAndSimilar":"Tripode con gamba extra e simili", + "taskTag_twoHeadedDragon":"Drago a due teste", + "taskTag_twoSpaceExtension":"Estensione a due spazi", + "taskTag_typesOfKo":"Tipi di ko", + "taskTag_underTheStones":"Ishi no shita (under the stones)", + "taskTag_underneathAttachment":"Tsuke da sotto", + "taskTag_urgentPointOfAFight":"Punti urgenti per il combattimento", + "taskTag_urgentPoints":"Punti urgenti", + "taskTag_useConnectAndDie":"Sfrutta oiotoshi (connect and die)", + "taskTag_useCornerSpecialProperties":"Usa le peculiarità dell'angolo", + "taskTag_useDescentToFirstLine":"Usa la discesa in prima linea", + "taskTag_useInfluence":"Usa l'influenza", + "taskTag_useOpponentsLifeAndDeath":"Sfrutta la vita-e-morte dell'avversario", + "taskTag_useShortageOfLiberties":"Sfrutta la carenza di libertà", + "taskTag_useSnapback":"Usa lo snapback", + "taskTag_useSurroundingStones":"Usa le pietre attorno", + "taskTag_vitalAndUselessStones":"Pietre vitali e pietre inutili", + "taskTag_vitalPointForBothSides":"Punti vitali per entrambi", + "taskTag_vitalPointForCapturingRace":"Punti vitali per il semeai", + "taskTag_vitalPointForIncreasingLiberties":"Punti vitali per aumentare le libertà", + "taskTag_vitalPointForKill":"Punti vitali per uccidere", + "taskTag_vitalPointForLife":"Punti vitali per vivere", + "taskTag_vitalPointForReducingLiberties":"Punti vitali per ridurre le libertà", + "taskTag_wedge":"Wedge", + "taskTag_wedgingCapture":"Cattura con wedge", + "taskTimeout": "Timeout", + "taskTypeAppreciation": "Valutazione", + "taskTypeCapture": "Cattura", + "taskTypeCaptureRace": "Semeai (capturing race)", + "taskTypeEndgame": "Yose (fine gioco)", + "taskTypeJoseki": "Joseki", + "taskTypeLifeAndDeath": "Life \u0026 death", + "taskTypeMiddlegame": "Chuban (mediogioco)", + "taskTypeOpening": "Fuseki (apertura)", + "taskTypeTesuji": "Tesuji", + "taskTypeTheory": "Teoria", + "taskWrong": "Sbagliato", + "tasksSolved": "Problema risolto", + "test": "Test", + "theme": "Tema", + "thick": "Spesso", + "timeFrenzy": "Frenesia", + "timeFrenzyMistakes": "Ricorda gli errori durante la Frenesia", + "timeFrenzyMistakesDesc": "Abilita il salvataggio degli errori commessi durante le sessioni di Frenesia", + "timePerTask": "Tempo problema", + "today": "Oggi", + "tooltipAnalyzeWithAISensei": "Analizza con AI Sensei", + "tooltipDownloadGame": "Scarica", + "topic": "Argomento", + "topicExam": "Argomento del test", + "topics": "Argomenti", + "train": "Allenamento", + "trainingAvgTimePerTask": "Tempo medio problema", + "trainingFailed": "Fallito", + "trainingMistakes": "Errori", + "trainingPassed": "Superato", + "trainingTotalTime": "Tempo totale", + "tryCustomMoves": "Prova altre mosse", + "tygemDesc": "Il server più popolare in Corea e uno dei più popolari al mondo.", + "tygemName": "Tygem Baduk", + "type": "Tipo", + "ui": "Interfaccia utente", + "userInfo": "Info utente", + "username": "Username", + "voice": "Voce", + "week": "Settimana", + "white": "Bianco", + "yes": "Si" +} \ 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..8f6d7b72 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,2806 @@ +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_de.dart'; +import 'app_localizations_en.dart'; +import 'app_localizations_es.dart'; +import 'app_localizations_it.dart'; +import 'app_localizations_ro.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('de'), + Locale('en'), + Locale('es'), + Locale('it'), + Locale('ro'), + Locale('ru'), + Locale('zh') + ]; + + /// No description provided for @about. + /// + /// In en, this message translates to: + /// **'About'** + String get about; + + /// No description provided for @acceptDeadStones. + /// + /// In en, this message translates to: + /// **'Accept dead stones'** + String get acceptDeadStones; + + /// 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 @clearBoard. + /// + /// In en, this message translates to: + /// **'Clear'** + String get clearBoard; + + /// 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 @copySGF. + /// + /// In en, this message translates to: + /// **'Copy SGF'** + String get copySGF; + + /// 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 @empty. + /// + /// In en, this message translates to: + /// **'Empty'** + String get empty; + + /// 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 @findTaskByLink. + /// + /// In en, this message translates to: + /// **'By link'** + String get findTaskByLink; + + /// No description provided for @findTaskByPattern. + /// + /// In en, this message translates to: + /// **'By pattern'** + String get findTaskByPattern; + + /// No description provided for @findTaskResults. + /// + /// In en, this message translates to: + /// **'Search results'** + String get findTaskResults; + + /// No description provided for @findTaskSearching. + /// + /// In en, this message translates to: + /// **'Searching...'** + String get findTaskSearching; + + /// 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 @hideTask. + /// + /// In en, this message translates to: + /// **'Remove from mistakes'** + String get hideTask; + + /// 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 @msgSgfCopied. + /// + /// In en, this message translates to: + /// **'SGF copied to clipboard'** + String get msgSgfCopied; + + /// 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: + /// **'An international server, most popular in Europe and the Americas.'** + 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 @pleaseMarkDeadStones. + /// + /// In en, this message translates to: + /// **'Please mark the dead stones.'** + String get pleaseMarkDeadStones; + + /// 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 @rejectDeadStones. + /// + /// In en, this message translates to: + /// **'Reject dead stones'** + String get rejectDeadStones; + + /// 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 @resultAccept. + /// + /// In en, this message translates to: + /// **'Accept'** + String get resultAccept; + + /// No description provided for @resultReject. + /// + /// In en, this message translates to: + /// **'Reject'** + String get resultReject; + + /// 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 @sSeconds. + /// + /// In en, this message translates to: + /// **'{s}s'** + String sSeconds(int s); + + /// 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 @showMoveErrorsAsCrosses. + /// + /// In en, this message translates to: + /// **'Display wrong moves as crosses'** + String get showMoveErrorsAsCrosses; + + /// No description provided for @showMoveErrorsAsCrossesDesc. + /// + /// In en, this message translates to: + /// **'Display wrong moves as red crosses instead of red dots'** + String get showMoveErrorsAsCrossesDesc; + + /// 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 @taskNotFound. + /// + /// In en, this message translates to: + /// **'Task not found'** + String get taskNotFound; + + /// 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 @timeFrenzyMistakes. + /// + /// In en, this message translates to: + /// **'Track Time Frenzy mistakes'** + String get timeFrenzyMistakes; + + /// No description provided for @timeFrenzyMistakesDesc. + /// + /// In en, this message translates to: + /// **'Enable to save mistakes made in Time Frenzy'** + String get timeFrenzyMistakesDesc; + + /// No description provided for @randomizeTaskOrientation. + /// + /// In en, this message translates to: + /// **'Randomize task orientation'** + String get randomizeTaskOrientation; + + /// No description provided for @randomizeTaskOrientationDesc. + /// + /// In en, this message translates to: + /// **'Randomly rotates and reflects tasks along horizontal, vertical, and diagonal axes to prevent memorization and enhance pattern recognition.'** + String get randomizeTaskOrientationDesc; + + /// 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) => [ + 'de', + 'en', + 'es', + 'it', + 'ro', + '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 'de': + return AppLocalizationsDe(); + case 'en': + return AppLocalizationsEn(); + case 'es': + return AppLocalizationsEs(); + case 'it': + return AppLocalizationsIt(); + case 'ro': + return AppLocalizationsRo(); + 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_de.dart b/lib/l10n/app_localizations_de.dart new file mode 100644 index 00000000..25f76b17 --- /dev/null +++ b/lib/l10n/app_localizations_de.dart @@ -0,0 +1,1425 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class AppLocalizationsDe extends AppLocalizations { + AppLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get about => 'Über'; + + @override + String get acceptDeadStones => 'Tote Steine akzeptieren'; + + @override + String get accuracy => 'Genauigkeit'; + + @override + String get aiReferee => 'KI-Schiedsrichter'; + + @override + String get aiSensei => 'AI Sensei'; + + @override + String get alwaysBlackToPlay => 'Immer Schwarz am Zug'; + + @override + String get alwaysBlackToPlayDesc => + 'Alle Aufgaben mit Schwarz am Zug aufbauen, um Verwechslung zu vermeiden'; + + @override + String get appearance => 'Aussehen'; + + @override + String get autoCounting => 'Automatisches Auszählen'; + + @override + String get autoMatch => 'Automatisches Match'; + + @override + String get behaviour => 'Verhalten'; + + @override + String get bestResult => 'Bestes Ergebnis'; + + @override + String get black => 'Schwarz'; + + @override + String get board => 'Brett'; + + @override + String get boardSize => 'Brettgröße'; + + @override + String get boardTheme => 'Brett Aussehen'; + + @override + String get byRank => 'Nach Rang'; + + @override + String get cancel => 'Abbrechen'; + + @override + String get captures => 'Gefangene'; + + @override + String get clearBoard => 'Brett leeren'; + + @override + String get collectStats => 'Statistiken erfassen'; + + @override + String get collections => 'Sammlungen'; + + @override + String get confirm => 'Bestätigen'; + + @override + String get confirmBoardSize => 'Brettgröße bestätigen'; + + @override + String get confirmBoardSizeDesc => + 'Bretter dieser Größe oder größer erfordern eine Zugbestätigung'; + + @override + String get confirmMoves => 'Züge bestätigen'; + + @override + String get confirmMovesDesc => + 'Doppelt tippen, um Züge auf großen Brettern zu bestätigen und Fehlklicks zu vermeiden'; + + @override + String get continue_ => 'Fortfahren'; + + @override + String get copySGF => 'SGF kopieren'; + + @override + String get copyTaskLink => 'Aufgabenlink kopieren'; + + @override + String get customExam => 'Benutzerdefinierte Prüfung'; + + @override + String get dark => 'Dunkel'; + + @override + String get dontShowAgain => 'Nicht erneut anzeigen'; + + @override + String get download => 'Herunterladen'; + + @override + String get edgeLine => 'Randlinie'; + + @override + String get empty => 'leer'; + + @override + String get endgameExam => 'Endspiel-Prüfung'; + + @override + String get enterTaskLink => 'Aufgabenlink eingeben'; + + @override + String get errCannotBeEmpty => 'Darf nicht leer sein'; + + @override + String get errFailedToDownloadGame => + 'Spiel konnte nicht heruntergeladen werden'; + + @override + String get errFailedToLoadGameList => + 'Spielliste konnte nicht geladen werden. Bitte versuche es erneut.'; + + @override + String get errFailedToUploadGameToAISensei => + 'Spiel konnte nicht zu AI Sensei hochgeladen werden'; + + @override + String get errIncorrectUsernameOrPassword => + 'Falscher Benutzername oder falsches Passwort'; + + @override + String errMustBeAtLeast(num n) { + return 'Muss mindestens $n sein'; + } + + @override + String errMustBeAtMost(num n) { + return 'Darf höchstens $n sein'; + } + + @override + String get errMustBeInteger => 'Muss eine ganze Zahl sein'; + + @override + String get exit => 'Beenden'; + + @override + String get exitTryMode => 'Testmodus beenden'; + + @override + String get find => 'Suchen'; + + @override + String get findTask => 'Problem suchen'; + + @override + String get findTaskByLink => 'Mit einem Link'; + + @override + String get findTaskByPattern => 'Mit Mustererkennung'; + + @override + String get findTaskResults => 'Suchergebnisse'; + + @override + String get findTaskSearching => 'Suche...'; + + @override + String get forceCounting => 'Auszählen erzwingen'; + + @override + String get foxwqDesc => 'Der beliebteste Server in China und weltweit.'; + + @override + String get foxwqName => 'Fox Weiqi'; + + @override + String get gameInfo => 'Spielinfo'; + + @override + String get gameRecord => 'Spielaufzeichnung'; + + @override + String get gradingExam => 'Einstufungsprüfung'; + + @override + String get handicap => 'Vorgabe'; + + @override + String get help => 'Hilfe'; + + @override + String get helpDialogCollections => + 'Kollektionen sind klassische, kuratierte Sets von hochwertigen Aufgaben, die gemeinsam einen besonderen Wert als Trainingsressource bilden.\n\nDas Hauptziel ist es, eine Kollektion mit einer hohen Erfolgsquote zu lösen. Ein Nebenziel ist es, sie so schnell wie möglich zu lösen.\n\nUm eine Kollektion zu starten oder fortzusetzen, wische im Hochformat nach links über die Kachel der Kollektion oder klicke im Querformat auf die Start-/Weiter-Buttons.'; + + @override + String get helpDialogEndgameExam => + '- Endspielprüfungen bestehen aus 10 Endspielaufgaben, und du hast 45 Sekunden pro Aufgabe.\n\n- Du bestehst die Prüfung, wenn du 8 oder mehr Aufgaben korrekt löst (80 % Erfolgsquote).\n\n- Das Bestehen der Prüfung für einen bestimmten Rang schaltet die Prüfung für den nächsten Rang frei.'; + + @override + String get helpDialogGradingExam => + '- Einstufungsprüfung bestehen aus 10 Aufgaben, und du hast 45 Sekunden pro Aufgabe.\n\n- Du bestehst die Prüfung, wenn du 8 oder mehr Aufgaben korrekt löst (80 % Erfolgsquote).\n\n- Das Bestehen der Prüfung für einen bestimmten Rang schaltet die Prüfung für den nächsten Rang frei.'; + + @override + String get helpDialogRankedMode => + '- Löse Aufgaben ohne Zeitlimit.\n\n- Die Schwierigkeit der Aufgaben steigt entsprechend deiner Lösungs­geschwindigkeit.\n\n- Konzentriere dich darauf, korrekt zu lösen und den höchstmöglichen Rang zu erreichen.'; + + @override + String get helpDialogTimeFrenzy => + '- Du hast 3 Minuten Zeit, um so viele Aufgaben wie möglich zu lösen.\n\n- Die Aufgaben werden zunehmend schwieriger, je mehr du löst.\n\n- Wenn du 3 Fehler machst, bist du raus.'; + + @override + String get hideTask => 'Aus Fehlern entfernen'; + + @override + String get home => 'Startseite'; + + @override + String get komi => 'Komi'; + + @override + String get language => 'Sprache'; + + @override + String get leave => 'Verlassen'; + + @override + String get light => 'Hell'; + + @override + String get login => 'Anmelden'; + + @override + String get logout => 'Abmelden'; + + @override + String get long => 'Lang'; + + @override + String mMinutes(int m) { + return '${m}min'; + } + + @override + String get maxNumberOfMistakes => 'Maximale Fehleranzahl'; + + @override + String get maxRank => 'Max. Rang'; + + @override + String get medium => 'Mittel'; + + @override + String get minRank => 'Min. Rang'; + + @override + String get minutes => 'Minuten'; + + @override + String get month => 'Monat'; + + @override + String get msgCannotUseAIRefereeYet => + 'Der KI-Schiedsrichter kann noch nicht verwendet werden'; + + @override + String get msgCannotUseForcedCountingYet => + 'Erzwungenes Zählen kann noch nicht verwendet werden'; + + @override + String get msgConfirmDeleteCollectionProgress => + 'Bist du sicher, dass du den bisherigen Fortschritt löschen möchtest?'; + + @override + String get msgConfirmResignation => + 'Bist du sicher, dass du aufgeben möchtest?'; + + @override + String msgConfirmStopEvent(String event) { + return 'Bist du sicher, dass du $event beenden möchtest?'; + } + + @override + String get msgDownloadingGame => 'Spiel wird heruntergeladen'; + + @override + String msgGameSavedTo(String path) { + return 'Spiel gespeichert unter $path'; + } + + @override + String get msgPleaseWaitForYourTurn => 'Bitte warte auf deinen Zug'; + + @override + String get msgSearchingForGame => 'Suche nach einem Spiel...'; + + @override + String get msgSgfCopied => 'SGF in die Zwischenablage kopiert.'; + + @override + String get msgTaskLinkCopied => 'Aufgabenlink kopiert.'; + + @override + String get msgWaitingForOpponentsDecision => + 'Warte auf die Entscheidung deines Gegners...'; + + @override + String get msgYouCannotPass => 'Du kannst nicht passen'; + + @override + String get msgYourOpponentDisagreesWithCountingResult => + 'Dein Gegner ist mit der Auszählung nicht einverstanden'; + + @override + String get msgYourOpponentRefusesToCount => + 'Dein Gegner weigert sich zu auszuzählen'; + + @override + String get msgYourOpponentRequestsAutomaticCounting => + 'Dein Gegner fordert automatisches Auszählen an. Bist du damit einverstanden?'; + + @override + String get myGames => 'Meine Spiele'; + + @override + String get myMistakes => 'Meine Fehler'; + + @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 Aufgaben', + one: '1 Aufgabe', + zero: 'Keine Aufgaben', + ); + 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 Aufgaben verfügbar', + one: '1 Aufgabe verfügbar', + zero: 'Keine Aufgaben verfügbar', + ); + return '$_temp0'; + } + + @override + String get newBestResult => 'Neuer Rekord!'; + + @override + String get no => 'Nein'; + + @override + String get none => 'Keine'; + + @override + String get numberOfTasks => 'Anzahl der Aufgaben'; + + @override + String nxnBoardSize(int n) { + return '$n×$n'; + } + + @override + String get ogsDesc => + 'Ein internationaler Server welcher am meisten in Europa und Amerika genutzt wird.'; + + @override + String get ogsName => 'Online Go Server'; + + @override + String get ok => 'OK'; + + @override + String get pass => 'Passen'; + + @override + String get password => 'Passwort'; + + @override + String get play => 'Spielen'; + + @override + String get pleaseMarkDeadStones => 'Bitte markiere die toten Steine.'; + + @override + String get promotionRequirements => 'Anforderungen zum Auftsieg'; + + @override + String pxsByoyomi(int p, int s) { + return '$p×${s}s'; + } + + @override + String get rank => 'Rang'; + + @override + String get rankedMode => 'Gewerteter Modus'; + + @override + String get recentRecord => 'Letzte Ergebnisse'; + + @override + String get register => 'Registrieren'; + + @override + String get rejectDeadStones => 'Tote Steine ablehnen'; + + @override + String get resign => 'Aufgeben'; + + @override + String get responseDelay => 'Antwortverzögerung'; + + @override + String get responseDelayDesc => + 'Dauer der Verzögerung, bevor beim Lösen einer Aufgabe die Antwort erscheint'; + + @override + String get responseDelayLong => 'Lang'; + + @override + String get responseDelayMedium => 'Mittel'; + + @override + String get responseDelayNone => 'Keine'; + + @override + String get responseDelayShort => 'Kurz'; + + @override + String get result => 'Ergebnis'; + + @override + String get resultAccept => 'Akzeptieren'; + + @override + String get resultReject => 'Ablehnen'; + + @override + String get rules => 'Regeln'; + + @override + String get rulesChinese => 'Chinesisch'; + + @override + String get rulesJapanese => 'Japanisch'; + + @override + String get rulesKorean => 'Koreanisch'; + + @override + String sSeconds(int s) { + return '${s}s'; + } + + @override + String get save => 'Sichern'; + + @override + String get saveSGF => 'SGF speichern'; + + @override + String get seconds => 'Sekunden'; + + @override + String get settings => 'Einstellungen'; + + @override + String get short => 'Kurz'; + + @override + String get showCoordinates => 'Koordinaten anzeigen'; + + @override + String get showMoveErrorsAsCrosses => 'Falsche Züge als Kreuz darstellen'; + + @override + String get showMoveErrorsAsCrossesDesc => + 'Falsche Züge werden als rotes Kreuz anstatt eines roiten Punkts angezeigt'; + + @override + String get simple => 'Einfach'; + + @override + String get sortModeDifficult => 'Schwierig'; + + @override + String get sortModeRecent => 'Neueste'; + + @override + String get sound => 'Ton'; + + @override + String get start => 'Start'; + + @override + String get statistics => 'Statistiken'; + + @override + String get statsDateColumn => 'Datum'; + + @override + String get statsDurationColumn => 'Dauer'; + + @override + String get statsTimeColumn => 'Zeit'; + + @override + String get stoneShadows => 'Steinschatten'; + + @override + String get stones => 'Steine'; + + @override + String get subtopic => 'Unterthema'; + + @override + String get system => 'System'; + + @override + String get task => 'Aufgabe'; + + @override + String get taskCorrect => 'Richtig'; + + @override + String get taskNext => 'Weiter'; + + @override + String get taskNotFound => 'Aufgabe nicht gefunden'; + + @override + String get taskRedo => 'Erneut versuchen'; + + @override + String get taskSource => 'Quelle der Aufgaben'; + + @override + String get taskSourceFromMyMistakes => 'Aus meinen Fehlern'; + + @override + String get taskSourceFromTaskTopic => 'Aus dem Aufgabenthema'; + + @override + String get taskSourceFromTaskTypes => 'Aus den Aufgabentypen'; + + @override + String get taskTag_afterJoseki => 'Nach dem joseki'; + + @override + String get taskTag_aiOpening => 'KI Eröffnung'; + + @override + String get taskTag_aiVariations => 'KI Variationen'; + + @override + String get taskTag_attack => 'Angriff'; + + @override + String get taskTag_attackAndDefenseInKo => 'Angriff und Verteidigung mit Ko'; + + @override + String get taskTag_attackAndDefenseOfCuts => + 'Angriff und Verteidigung von Schnitten'; + + @override + String get taskTag_attackAndDefenseOfInvadingStones => + 'Angriff und Verteidigung von Invasionen'; + + @override + String get taskTag_avoidKo => 'Ko vermeiden'; + + @override + String get taskTag_avoidMakingDeadShape => 'Tote Form vermeiden'; + + @override + String get taskTag_avoidTrap => 'Fallen ausweichen'; + + @override + String get taskTag_basicEndgame => 'Endspiel: Grundlagen'; + + @override + String get taskTag_basicLifeAndDeath => 'Leben & Tod: Grundlagen'; + + @override + String get taskTag_basicMoves => 'Grundlagen'; + + @override + String get taskTag_basicTesuji => 'Tesuji'; + + @override + String get taskTag_beginner => 'Beginner'; + + @override + String get taskTag_bend => 'Umbiegen'; + + @override + String get taskTag_bentFour => 'Toter Winkel'; + + @override + String get taskTag_bentFourInTheCorner => 'Toter Winkel in der Ecke'; + + @override + String get taskTag_bentThree => 'Gebogene Drei'; + + @override + String get taskTag_bigEyeLiberties => 'Freiheiten von großen Augen'; + + @override + String get taskTag_bigEyeVsSmallEye => 'Großes Auge vs kleines Auge'; + + @override + String get taskTag_bigPoints => 'Große Punkte'; + + @override + String get taskTag_blindSpot => 'Blinder Fleck'; + + @override + String get taskTag_breakEye => 'Augen verhindern'; + + @override + String get taskTag_breakEyeInOneStep => 'Augen verhindern in einem Zug'; + + @override + String get taskTag_breakEyeInSente => 'Augen verhindern in Vorhand'; + + @override + String get taskTag_breakOut => 'Ausbrechen'; + + @override + String get taskTag_breakPoints => 'Punkte verhindern'; + + @override + String get taskTag_breakShape => 'Form verhindern'; + + @override + String get taskTag_bridgeUnder => 'Untenrum verbinden'; + + @override + String get taskTag_brilliantSequence => 'Brillante Sequenz'; + + @override + String get taskTag_bulkyFive => 'Klotzige-Fünf'; + + @override + String get taskTag_bump => 'Stoßen'; + + @override + String get taskTag_captureBySnapback => 'Fang durch Mausefalle'; + + @override + String get taskTag_captureInLadder => 'Fang in der Treppe'; + + @override + String get taskTag_captureInOneMove => 'Fang in einem Zug'; + + @override + String get taskTag_captureOnTheSide => 'Fang an der Seite'; + + @override + String get taskTag_captureToLive => 'Fang um zu leben'; + + @override + String get taskTag_captureTwoRecaptureOne => + 'Zwei fangen, einen zurückgewinnen'; + + @override + String get taskTag_capturingRace => 'Freiheitskampf'; + + @override + String get taskTag_capturingTechniques => 'Fangtechniken'; + + @override + String get taskTag_carpentersSquareAndSimilar => + 'Zimmermannswinkel und ähnliches'; + + @override + String get taskTag_chooseTheFight => 'Wähle den Kampf'; + + @override + String get taskTag_clamp => 'Klemmzug'; + + @override + String get taskTag_clampCapture => 'Klemmzug-Fang'; + + @override + String get taskTag_closeInCapture => 'Closing-in Fang'; + + @override + String get taskTag_combination => 'Kombination'; + + @override + String get taskTag_commonLifeAndDeath => 'Leben & Tod: Häufige Formen'; + + @override + String get taskTag_compareSize => 'Größe vergleichen'; + + @override + String get taskTag_compareValue => 'Werte vergleichen'; + + @override + String get taskTag_completeKoToSecureEndgameAdvantage => + 'Ko abschließen, um Vorteil im Endspiel zu sichern'; + + @override + String get taskTag_compositeProblems => 'Zusammengesetzte Aufgaben'; + + @override + String get taskTag_comprehensiveTasks => 'Umfassende Aufgaben'; + + @override + String get taskTag_connect => 'Verbinden'; + + @override + String get taskTag_connectAndDie => 'Verbinden und sterben'; + + @override + String get taskTag_connectInOneMove => 'Verbinden in einem Zug'; + + @override + String get taskTag_contactFightTesuji => 'Nahkampf Tesuji'; + + @override + String get taskTag_contactPlay => 'Anlegen'; + + @override + String get taskTag_corner => 'Ecke'; + + @override + String get taskTag_cornerIsGoldSideIsSilverCenterIsGrass => + 'Ecken sind Gold, Seiten sind Silber, das Zentrum ist Gras'; + + @override + String get taskTag_counter => 'Wiederlegezug'; + + @override + String get taskTag_counterAttack => 'Gegenangriff'; + + @override + String get taskTag_cranesNest => 'Kranichnest'; + + @override + String get taskTag_crawl => 'Kriechen'; + + @override + String get taskTag_createShortageOfLiberties => 'Freiheitsnot schaffen'; + + @override + String get taskTag_crossedFive => 'Stern-Fünf'; + + @override + String get taskTag_cut => 'Schneiden'; + + @override + String get taskTag_cut2 => 'Schneiden'; + + @override + String get taskTag_cutAcross => 'Schneiden'; + + @override + String get taskTag_defendFromInvasion => 'Vor Invasion verteidigen'; + + @override + String get taskTag_defendPoints => 'Punkte verteidigen'; + + @override + String get taskTag_defendWeakPoint => 'Schwachen Punkt verteidigen'; + + @override + String get taskTag_descent => 'Hinabstoßen'; + + @override + String get taskTag_diagonal => 'Diagonal'; + + @override + String get taskTag_directionOfCapture => 'Richtung des Fangens'; + + @override + String get taskTag_directionOfEscape => 'Richtung des Entkommens'; + + @override + String get taskTag_directionOfPlay => 'Richtung des Spiels'; + + @override + String get taskTag_doNotUnderestimateOpponent => 'Gegner nicht unterschätzen'; + + @override + String get taskTag_doubleAtari => 'Doppel Atari'; + + @override + String get taskTag_doubleCapture => 'Doppel-Fang'; + + @override + String get taskTag_doubleKo => 'Doppel-Ko'; + + @override + String get taskTag_doubleSenteEndgame => 'Doppel-Vorhand-Endspiel'; + + @override + String get taskTag_doubleSnapback => 'Doppel-Mausefalle'; + + @override + String get taskTag_endgame => 'Endspiel: Allgemein'; + + @override + String get taskTag_endgameFundamentals => 'Endspielgrundlagen'; + + @override + String get taskTag_endgameIn5x5 => 'Endspiel auf 5x5'; + + @override + String get taskTag_endgameOn4x4 => 'Endspiel auf 4x4'; + + @override + String get taskTag_endgameTesuji => 'Endspiel Tesuji'; + + @override + String get taskTag_engulfingAtari => 'Engulfing Atari'; + + @override + String get taskTag_escape => 'Entkommen'; + + @override + String get taskTag_escapeInOneMove => 'Entkommen in einem Zug'; + + @override + String get taskTag_exploitShapeWeakness => 'Formschwäche ausnutzen'; + + @override + String get taskTag_eyeVsNoEye => 'Auge vs kein Auge'; + + @override + String get taskTag_fillNeutralPoints => 'Neutrale Punkte füllen'; + + @override + String get taskTag_findTheRoot => 'Die Wurzel finden'; + + @override + String get taskTag_firstLineBrilliantMove => + 'brillante Züge auf der ersten Linie'; + + @override + String get taskTag_flowerSix => 'Blumen-Sechs'; + + @override + String get taskTag_goldenChickenStandingOnOneLeg => + 'Der goldene Hahn steht auf einem Bein'; + + @override + String get taskTag_groupLiberties => 'Gruppenfreiheiten'; + + @override + String get taskTag_groupsBase => 'Basis der Gruppe'; + + @override + String get taskTag_hane => 'Hane'; + + @override + String get taskTag_increaseEyeSpace => 'Augenraum vergrößern'; + + @override + String get taskTag_increaseLiberties => 'Freiheiten vermehren'; + + @override + String get taskTag_indirectAttack => 'Indirekter Angriff'; + + @override + String get taskTag_influenceKeyPoints => 'Schlüsselstellen für Einfluss'; + + @override + String get taskTag_insideKill => 'Töten von innen'; + + @override + String get taskTag_insideMoves => 'Züge im Inneren'; + + @override + String get taskTag_interestingTasks => 'Interessante Aufgaben'; + + @override + String get taskTag_internalLibertyShortage => 'Interne Freiheitsnot'; + + @override + String get taskTag_invadingTechnique => 'Invasionstechnik'; + + @override + String get taskTag_invasion => 'Invasion'; + + @override + String get taskTag_jGroupAndSimilar => 'J-Gruppe und ähnliche'; + + @override + String get taskTag_josekiFundamentals => 'Joseki-Grundlagen'; + + @override + String get taskTag_jump => 'Springen'; + + @override + String get taskTag_keepSente => 'Vorhand behalten'; + + @override + String get taskTag_killAfterCapture => 'Töten nach dem Fang'; + + @override + String get taskTag_killByEyePointPlacement => + 'Töten durch Platzierung des Augenpunkts'; + + @override + String get taskTag_knightsMove => 'Rösselsprung'; + + @override + String get taskTag_ko => 'Ko'; + + @override + String get taskTag_kosumiWedge => 'Kosumi Keil'; + + @override + String get taskTag_largeKnightsMove => 'Großer Rösselsprung'; + + @override + String get taskTag_largeMoyoFight => 'Großer Moyo-Kampf'; + + @override + String get taskTag_lifeAndDeath => 'Leben & Tod: Allgemein'; + + @override + String get taskTag_lifeAndDeathOn4x4 => 'Leben und Tod auf 4x4'; + + @override + String get taskTag_lookForLeverage => 'Nach Hebelwirkung suchen'; + + @override + String get taskTag_looseLadder => 'Lose Treppe'; + + @override + String get taskTag_lovesickCut => 'Liebeskummer Schnitt'; + + @override + String get taskTag_makeEye => 'Augen machen'; + + @override + String get taskTag_makeEyeInOneStep => 'Augen in einem Zug machen'; + + @override + String get taskTag_makeEyeInSente => 'Augen in Vorhand machen'; + + @override + String get taskTag_makeKo => 'Ko machen'; + + @override + String get taskTag_makeShape => 'Form machen'; + + @override + String get taskTag_middlegame => 'Mittelspiel'; + + @override + String get taskTag_monkeyClimbingMountain => 'Affe klettert den Berg'; + + @override + String get taskTag_mouseStealingOil => 'Maus stiehlt Öl'; + + @override + String get taskTag_moveOut => 'Entkommen'; + + @override + String get taskTag_moveTowardsEmptySpace => + 'In Richtung freies Gebiet laufen'; + + @override + String get taskTag_multipleBrilliantMoves => 'Mehrere brillante Züge'; + + @override + String get taskTag_net => 'Netz'; + + @override + String get taskTag_netCapture => 'Netzfang'; + + @override + String get taskTag_observeSubtleDifference => + 'Subtile Unterschiede beobachten'; + + @override + String get taskTag_occupyEncloseAndApproachCorner => + 'Besetzen, umschließen und Ecken annähern'; + + @override + String get taskTag_oneStoneTwoPurposes => 'Ein Stein, zwei Zwecke'; + + @override + String get taskTag_opening => 'Eröffnung'; + + @override + String get taskTag_openingChoice => 'Eröffnungswahl'; + + @override + String get taskTag_openingFundamentals => 'Eröffnungsgrundlagen'; + + @override + String get taskTag_orderOfEndgameMoves => 'Reihenfolge der Endspielzüge'; + + @override + String get taskTag_orderOfMoves => 'Reihenfolge der Züge'; + + @override + String get taskTag_orderOfMovesInKo => 'ZUgreihenfolge im Ko'; + + @override + String get taskTag_orioleCapturesButterfly => 'Pirol fängt den Schmetterling'; + + @override + String get taskTag_pincer => 'Klemmzug'; + + @override + String get taskTag_placement => 'Oki (Platzierung)'; + + @override + String get taskTag_plunderingTechnique => 'Plundering Technik'; + + @override + String get taskTag_preventBambooJoint => 'Bambus-Verbindung verhindern'; + + @override + String get taskTag_preventBridgingUnder => 'Unterverbinden verhindern'; + + @override + String get taskTag_preventOpponentFromApproaching => 'Annähern verhindern'; + + @override + String get taskTag_probe => 'Testzug'; + + @override + String get taskTag_profitInSente => 'In Vorhand profitieren'; + + @override + String get taskTag_profitUsingLifeAndDeath => 'Vorteil durch Leben und Tod'; + + @override + String get taskTag_push => 'Oshi (Schieben)'; + + @override + String get taskTag_pyramidFour => 'Vierer-Pyramide'; + + @override + String get taskTag_realEyeAndFalseEye => 'Echtes Auge vs falsches Auge'; + + @override + String get taskTag_rectangularSix => 'Rechteckige Sechs'; + + @override + String get taskTag_reduceEyeSpace => 'Augenraum reduzieren'; + + @override + String get taskTag_reduceLiberties => 'Freiheiten reduzieren'; + + @override + String get taskTag_reduction => 'Reduktion'; + + @override + String get taskTag_runWeakGroup => 'Schwache Gruppe laufen'; + + @override + String get taskTag_sabakiAndUtilizingInfluence => + 'Sabaki und Einfluss nutzen'; + + @override + String get taskTag_sacrifice => 'Opfer'; + + @override + String get taskTag_sacrificeAndSqueeze => 'Opfer und Auspressen'; + + @override + String get taskTag_sealIn => 'Einschließen'; + + @override + String get taskTag_secondLine => 'Zweite Linie'; + + @override + String get taskTag_seizeTheOpportunity => 'Die Gelegenheit ergreifen'; + + @override + String get taskTag_seki => 'Seki'; + + @override + String get taskTag_senteAndGote => 'Sente und Gote'; + + @override + String get taskTag_settleShape => 'Form festlegen'; + + @override + String get taskTag_settleShapeInSente => 'Form in Vorhand festlegen'; + + @override + String get taskTag_shape => 'Form'; + + @override + String get taskTag_shapesVitalPoint => 'Vitaler Punkt der Form'; + + @override + String get taskTag_side => 'Seite'; + + @override + String get taskTag_smallBoardEndgame => 'Ensdspiel auf kleinem Brett'; + + @override + String get taskTag_snapback => 'Mausefalle'; + + @override + String get taskTag_solidConnection => 'Feste Verbindung'; + + @override + String get taskTag_solidExtension => 'Feste Erweiterung'; + + @override + String get taskTag_splitInOneMove => 'In einem Zug teilen'; + + @override + String get taskTag_splittingMove => 'Teilungszug'; + + @override + String get taskTag_squareFour => 'Klotzige-Vier'; + + @override + String get taskTag_squeeze => 'Auspressen'; + + @override + String get taskTag_standardCapturingRaces => 'Standard Freiheitskämpfe'; + + @override + String get taskTag_standardCornerAndSideEndgame => + 'Standard Ecken- und Seiten-Endspiel'; + + @override + String get taskTag_straightFour => 'Gerader Vierer'; + + @override + String get taskTag_straightThree => 'Gerader Dreier'; + + @override + String get taskTag_surroundTerritory => 'Territorium umschließen'; + + @override + String get taskTag_symmetricShape => 'Symmetrische Form'; + + @override + String get taskTag_techniqueForReinforcingGroups => + 'Technik zur Verstärkung von Gruppen'; + + @override + String get taskTag_techniqueForSecuringTerritory => + 'Technik zur Sicherung des Territoriums'; + + @override + String get taskTag_textbookTasks => 'Textbuch-Aufgaben'; + + @override + String get taskTag_thirdAndFourthLine => 'Dritte und vierte Linie'; + + @override + String get taskTag_threeEyesTwoActions => 'Drei Augen, zwei Aktionen'; + + @override + String get taskTag_threeSpaceExtensionFromTwoStones => + 'Drei-Raum-Erweiterung von zwei Steinen'; + + @override + String get taskTag_throwIn => 'Einwerfen'; + + @override + String get taskTag_tigersMouth => 'Tigerrachen'; + + @override + String get taskTag_tombstoneSqueeze => 'Grabstein Auspressen'; + + @override + String get taskTag_tripodGroupWithExtraLegAndSimilar => + 'Tripod-Gruppe mit zusätzlichem Bein und ähnlichem'; + + @override + String get taskTag_twoHaneGainOneLiberty => + 'Doppel-Hane gewinnt eine Freiheit'; + + @override + String get taskTag_twoHeadedDragon => 'Zweiköpfiger Drache'; + + @override + String get taskTag_twoSpaceExtension => 'Zwei-Raum-Erweiterung'; + + @override + String get taskTag_typesOfKo => 'Arten von Ko'; + + @override + String get taskTag_underTheStones => 'Unter den Steinen'; + + @override + String get taskTag_underneathAttachment => 'Shitatsuke (unterhalb Anlegen)'; + + @override + String get taskTag_urgentPointOfAFight => 'Dringender Punkt eines Kampfes'; + + @override + String get taskTag_urgentPoints => 'Dringende Punkte'; + + @override + String get taskTag_useConnectAndDie => 'Verbinden und sterben'; + + @override + String get taskTag_useCornerSpecialProperties => + 'Spezielle Eigenschaften der Ecke nutzen'; + + @override + String get taskTag_useDescentToFirstLine => 'Abstieg zur ersten Linie nutzen'; + + @override + String get taskTag_useInfluence => 'Einfluss nutzen'; + + @override + String get taskTag_useOpponentsLifeAndDeath => + 'Das Leben und Tod des Gegners nutzen'; + + @override + String get taskTag_useShortageOfLiberties => 'Mangel an Freiheiten nutzen'; + + @override + String get taskTag_useSnapback => 'Mausefalle nutzen'; + + @override + String get taskTag_useSurroundingStones => 'Umgebende Steine nutzen'; + + @override + String get taskTag_vitalAndUselessStones => 'Vitale und nutzlose Steine'; + + @override + String get taskTag_vitalPointForBothSides => 'Vitaler Punkt für beide Seiten'; + + @override + String get taskTag_vitalPointForCapturingRace => + 'Vitaler Punkt für den Freiheitskampf'; + + @override + String get taskTag_vitalPointForIncreasingLiberties => + 'Vitaler Punkt zur Erhöhung der Freiheiten'; + + @override + String get taskTag_vitalPointForKill => 'Vitaler Punkt für das Töten'; + + @override + String get taskTag_vitalPointForLife => 'Vitaler Punkt für das Leben'; + + @override + String get taskTag_vitalPointForReducingLiberties => + 'Vitaler Punkt zur Verringerung der Freiheiten'; + + @override + String get taskTag_wedge => 'Warikomi (Keil)'; + + @override + String get taskTag_wedgingCapture => 'Warikomi-Fang'; + + @override + String get taskTimeout => 'Zeitüberschreitung'; + + @override + String get taskTypeAppreciation => 'Wertschätzung'; + + @override + String get taskTypeCapture => 'Steine fangen'; + + @override + String get taskTypeCaptureRace => 'Freiheitskampf'; + + @override + String get taskTypeEndgame => 'Endspiel'; + + @override + String get taskTypeJoseki => 'Joseki'; + + @override + String get taskTypeLifeAndDeath => 'Leben & Tod'; + + @override + String get taskTypeMiddlegame => 'Mittelspiel'; + + @override + String get taskTypeOpening => 'Eröffnung'; + + @override + String get taskTypeTesuji => 'Tesuji'; + + @override + String get taskTypeTheory => 'Theorie'; + + @override + String get taskWrong => 'Falsch'; + + @override + String get tasksSolved => 'Gelöste Aufgaben'; + + @override + String get test => 'Test'; + + @override + String get theme => 'Thema'; + + @override + String get thick => 'Dick'; + + @override + String get timeFrenzy => 'Zeitrausch'; + + @override + String get timeFrenzyMistakes => 'Zeitrausch Fehler'; + + @override + String get timeFrenzyMistakesDesc => + 'Fehler im Zeitrausch Modus werden gespeichert'; + + @override + String get randomizeTaskOrientation => 'Zufällige Aufgabenorientierung'; + + @override + String get randomizeTaskOrientationDesc => + 'Zufälliges Drehen und Spiegeln von Aufgaben entlang horizontaler, vertikaler und diagonaler Achsen, um Auswendiglernen zu verhindern und die Mustererkennung zu verbessern.'; + + @override + String get timePerTask => 'Zeit pro Aufgabe'; + + @override + String get today => 'Heute'; + + @override + String get tooltipAnalyzeWithAISensei => 'Mit AI Sensei analysieren'; + + @override + String get tooltipDownloadGame => 'Spiel herunterladen'; + + @override + String get topic => 'Thema'; + + @override + String get topicExam => 'Themen Prüfung'; + + @override + String get topics => 'Themen'; + + @override + String get train => 'Trainieren'; + + @override + String get trainingAvgTimePerTask => 'Durchschn. Zeit pro Aufgabe'; + + @override + String get trainingFailed => 'Nicht bestanden'; + + @override + String get trainingMistakes => 'Fehler'; + + @override + String get trainingPassed => 'Bestanden'; + + @override + String get trainingTotalTime => 'Gesamtzeit'; + + @override + String get tryCustomMoves => 'Eigene Züge ausprobieren'; + + @override + String get tygemDesc => + 'Der beliebteste Server in Korea und einer der beliebtesten weltweit.'; + + @override + String get tygemName => 'Tygem Baduk'; + + @override + String get type => 'Typ'; + + @override + String get ui => 'UI'; + + @override + String get userInfo => 'Benutzerinfo'; + + @override + String get username => 'Benutzername'; + + @override + String get voice => 'Stimme'; + + @override + String get week => 'Woche'; + + @override + String get white => 'Weiß'; + + @override + String get yes => 'Ja'; +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 00000000..9f1410b7 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,1416 @@ +// 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 acceptDeadStones => 'Accept dead stones'; + + @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 clearBoard => 'Clear'; + + @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 copySGF => 'Copy SGF'; + + @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 empty => 'Empty'; + + @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 findTaskByLink => 'By link'; + + @override + String get findTaskByPattern => 'By pattern'; + + @override + String get findTaskResults => 'Search results'; + + @override + String get findTaskSearching => 'Searching...'; + + @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 hideTask => 'Remove from mistakes'; + + @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 msgSgfCopied => 'SGF copied to clipboard'; + + @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 => + 'An international server, most popular in Europe and the Americas.'; + + @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 pleaseMarkDeadStones => 'Please mark the dead stones.'; + + @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 rejectDeadStones => 'Reject dead stones'; + + @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 resultAccept => 'Accept'; + + @override + String get resultReject => 'Reject'; + + @override + String get rules => 'Rules'; + + @override + String get rulesChinese => 'Chinese'; + + @override + String get rulesJapanese => 'Japanese'; + + @override + String get rulesKorean => 'Korean'; + + @override + String sSeconds(int s) { + return '${s}s'; + } + + @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 showMoveErrorsAsCrosses => 'Display wrong moves as crosses'; + + @override + String get showMoveErrorsAsCrossesDesc => + 'Display wrong moves as red crosses instead of red dots'; + + @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 taskNotFound => 'Task not found'; + + @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 timeFrenzyMistakes => 'Track Time Frenzy mistakes'; + + @override + String get timeFrenzyMistakesDesc => + 'Enable to save mistakes made in Time Frenzy'; + + @override + String get randomizeTaskOrientation => 'Randomize task orientation'; + + @override + String get randomizeTaskOrientationDesc => + 'Randomly rotates and reflects tasks along horizontal, vertical, and diagonal axes to prevent memorization and enhance pattern recognition.'; + + @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..92d9020a --- /dev/null +++ b/lib/l10n/app_localizations_es.dart @@ -0,0 +1,1423 @@ +// 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 acceptDeadStones => 'Aceptar piedras muertas'; + + @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 clearBoard => 'Limpiar'; + + @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 copySGF => 'Copiar SGF'; + + @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 empty => 'Vacío'; + + @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 findTaskByLink => 'Con enlace'; + + @override + String get findTaskByPattern => 'Con patrón'; + + @override + String get findTaskResults => 'Resultados de búsqueda'; + + @override + String get findTaskSearching => 'Buscando...'; + + @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 hideTask => 'Quitar de errores'; + + @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áx.'; + + @override + String get medium => 'Media'; + + @override + String get minRank => 'Rango mín.'; + + @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 msgSgfCopied => 'SGF copiado al portapapeles'; + + @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 => + 'Un servidor internacional, más popular en Europa y las Américas.'; + + @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 pleaseMarkDeadStones => 'Por favor, marca las piedras muertas.'; + + @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 rejectDeadStones => 'Rechazar piedras muertas'; + + @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 resultAccept => 'Aceptar'; + + @override + String get resultReject => 'Rechazar'; + + @override + String get rules => 'Reglas'; + + @override + String get rulesChinese => 'Chinas'; + + @override + String get rulesJapanese => 'Japonesas'; + + @override + String get rulesKorean => 'Coreanas'; + + @override + String sSeconds(int s) { + return '${s}s'; + } + + @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 showMoveErrorsAsCrosses => + 'Mostrar jugadas incorrectas como cruces'; + + @override + String get showMoveErrorsAsCrossesDesc => + 'Mostrar jugadas incorrectas como cruces rojas en lugar de puntos rojos'; + + @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 taskNotFound => 'No se encontró el problema'; + + @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 => 'Escapar'; + + @override + String get taskTag_breakPoints => 'Destruir puntos'; + + @override + String get taskTag_breakShape => 'Destruir forma'; + + @override + String get taskTag_bridgeUnder => 'Conección submarina'; + + @override + String get taskTag_brilliantSequence => 'Secuencia brillante'; + + @override + String get taskTag_bulkyFive => 'Auto'; + + @override + String get taskTag_bump => 'Golpe'; + + @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 => 'Captura de cierre'; + + @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 => 'Arrastre'; + + @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 => 'Corte transversal'; + + @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 => 'Atari de envoltura'; + + @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 => 'Encuentra la raíz'; + + @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 => 'Mata con jugada interna'; + + @override + String get taskTag_insideMoves => 'Jugadas internas'; + + @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 => 'Hocico de gran cerdo 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 => + 'Mata con jugada en ojo interno'; + + @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 => 'Peleas de moyo de larga escala'; + + @override + String get taskTag_lifeAndDeath => 'Vida y muerte: general'; + + @override + String get taskTag_lifeAndDeathOn4x4 => 'Vida y muerte en 4x4'; + + @override + String get taskTag_lookForLeverage => 'Busca palanca'; + + @override + String get taskTag_looseLadder => 'Escalera larga'; + + @override + String get taskTag_lovesickCut => 'Corte del amor'; + + @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 => 'El mono escala la montaña'; + + @override + String get taskTag_mouseStealingOil => 'El ratón roba aceite'; + + @override + String get taskTag_moveOut => 'Moverse al exterior'; + + @override + String get taskTag_moveTowardsEmptySpace => 'Moverse hacia el espacio vacío'; + + @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 => + 'El turpial captura a la mariposa'; + + @override + String get taskTag_pincer => 'Pinza'; + + @override + String get taskTag_placement => 'Jugada de colocación'; + + @override + String get taskTag_plunderingTechnique => 'Técnica de saqueo'; + + @override + String get taskTag_preventBambooJoint => 'Prevén la conección de bambú'; + + @override + String get taskTag_preventBridgingUnder => 'Prevén la conección submarina'; + + @override + String get taskTag_preventOpponentFromApproaching => + 'Prevén que el oponente se acerque'; + + @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 => 'Empuje'; + + @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 => + 'Extensión de tres puntos desde dos piedras'; + + @override + String get taskTag_throwIn => 'Sacrificio interno'; + + @override + String get taskTag_tigersMouth => 'Boca de tigre'; + + @override + String get taskTag_tombstoneSqueeze => 'Fantasma cabezón'; + + @override + String get taskTag_tripodGroupWithExtraLegAndSimilar => + 'Hocico de cerdito y similares'; + + @override + String get taskTag_twoHaneGainOneLiberty => + 'Gana una libertad con doble hane'; + + @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 => 'Sacrificio y contracaptura'; + + @override + String get taskTag_underneathAttachment => 'Apego submarino'; + + @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 timeFrenzyMistakes => 'Rastrear errores en Contrarreloj'; + + @override + String get timeFrenzyMistakesDesc => + 'Habilitar para guardar errores cometidos en Contrarreloj'; + + @override + String get randomizeTaskOrientation => 'Orientación aleatoria de problemas'; + + @override + String get randomizeTaskOrientationDesc => + 'Rota y refleja aleatoriamente los problemas a lo largo de los ejes horizontal, vertical y diagonal para evitar la memorización y mejorar el reconocimiento de patrones.'; + + @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_it.dart b/lib/l10n/app_localizations_it.dart new file mode 100644 index 00000000..2502291e --- /dev/null +++ b/lib/l10n/app_localizations_it.dart @@ -0,0 +1,1420 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Italian (`it`). +class AppLocalizationsIt extends AppLocalizations { + AppLocalizationsIt([String locale = 'it']) : super(locale); + + @override + String get about => 'Informazioni'; + + @override + String get acceptDeadStones => 'Accetta pietre catturate'; + + @override + String get accuracy => 'Precisione'; + + @override + String get aiReferee => 'Arbitro AI'; + + @override + String get aiSensei => 'AI Sensei'; + + @override + String get alwaysBlackToPlay => 'Black to play!'; + + @override + String get alwaysBlackToPlayDesc => + 'Impone la prima mossa al Nero per evitare confusione nei problemi'; + + @override + String get appearance => 'Aspetto'; + + @override + String get autoCounting => 'Conteggio automatico'; + + @override + String get autoMatch => 'Abbinamento automatico'; + + @override + String get behaviour => 'Preferenze'; + + @override + String get bestResult => 'Record'; + + @override + String get black => 'Nero'; + + @override + String get board => 'Goban'; + + @override + String get boardSize => 'Dimensioni'; + + @override + String get boardTheme => 'Stile'; + + @override + String get byRank => 'Per livello'; + + @override + String get cancel => 'Annulla'; + + @override + String get captures => 'Prigionieri'; + + @override + String get clearBoard => 'Svuota'; + + @override + String get collectStats => 'Registra statistiche'; + + @override + String get collections => 'Raccolte'; + + @override + String get confirm => 'Conferma'; + + @override + String get confirmBoardSize => 'Conferma in base alla dimensione del goban'; + + @override + String get confirmBoardSizeDesc => + 'Per goban di queste dimensioni o maggiori, chiedi di confermare la mossa con un doppio tocco'; + + @override + String get confirmMoves => 'Conferma la mossa'; + + @override + String get confirmMovesDesc => + 'Richiedi il doppio tocco per confermare la mossa sui goban più grandi (evita misclick)'; + + @override + String get continue_ => 'Continua'; + + @override + String get copySGF => 'Copia SGF'; + + @override + String get copyTaskLink => 'Copia link'; + + @override + String get customExam => 'Esame personalizzato'; + + @override + String get dark => 'Scuro'; + + @override + String get dontShowAgain => 'Non mostrare più'; + + @override + String get download => 'Download'; + + @override + String get edgeLine => 'Bordo'; + + @override + String get empty => 'Vuoto'; + + @override + String get endgameExam => 'Esame sul fine gioco'; + + @override + String get enterTaskLink => 'Inserisci il link'; + + @override + String get errCannotBeEmpty => 'Non può essere vuoto'; + + @override + String get errFailedToDownloadGame => 'Scaricamento partita fallito'; + + @override + String get errFailedToLoadGameList => + 'Impossibile caricare l\'elenco delle partite. Per favore riprova più tardi.'; + + @override + String get errFailedToUploadGameToAISensei => + 'Impossibile caricare la partita su AI Sensei'; + + @override + String get errIncorrectUsernameOrPassword => + 'Username o password non corretti'; + + @override + String errMustBeAtLeast(num n) { + return 'Deve essere almeno $n'; + } + + @override + String errMustBeAtMost(num n) { + return 'Deve essere al massimo $n'; + } + + @override + String get errMustBeInteger => 'Deve essere un numero intero'; + + @override + String get exit => 'Esci'; + + @override + String get exitTryMode => 'Ritorna'; + + @override + String get find => 'Trova'; + + @override + String get findTask => 'Trova problema'; + + @override + String get findTaskByLink => 'Per link'; + + @override + String get findTaskByPattern => 'Per posizione'; + + @override + String get findTaskResults => 'Risultati'; + + @override + String get findTaskSearching => 'Ricerca in corso...'; + + @override + String get forceCounting => 'Forza conteggio'; + + @override + String get foxwqDesc => 'Il server più popolare in Cina e nel mondo.'; + + @override + String get foxwqName => 'Fox Weiqi'; + + @override + String get gameInfo => 'Info partita'; + + @override + String get gameRecord => 'Risultato partita'; + + @override + String get gradingExam => 'Esame di livello'; + + @override + String get handicap => 'Handicap'; + + @override + String get help => 'Aiuto'; + + @override + String get helpDialogCollections => + 'Le raccolte sono insiemi classici e curati di problemi di alta qualità che hanno un valore speciale come risorsa formativa. L\'obiettivo principale è risolvere una raccolta con un alto tasso di successo. Un obiettivo secondario è risolverla il più velocemente possibile. Per iniziare o continuare a risolvere una raccolta di problemi, scorri verso sinistra sul riquadro della raccolta in modalità verticale o fai clic sui pulsanti Inizia/Continua in modalità orizzontale.'; + + @override + String get helpDialogEndgameExam => + '- L\'esame sul fine gioco contiene 10 problemi di fine gioco. Hai 45 secondi di tempo per risolvere ogni problema.\n\n- Superi la prova se risolvi correttamente almeno 8 problemi (tasso di successo: 80%).\n\n- Superare un livello sblocca l\'esame per il livello successivo.'; + + @override + String get helpDialogGradingExam => + '- L\'esame di livello contiene 10 problemi. Hai 45 secondi di tempo per risolvere ogni problema.\n\n- Superi la prova se risolvi correttamente almeno 8 problemi (tasso di successo: 80%).\n\n- Superare un livello sblocca l\'esame per il livello successivo.'; + + @override + String get helpDialogRankedMode => + '- Risolvi i problemi senza limiti di tempo.\n\n- La difficoltà aumenta in base alla tua rapidità.\n\n- Impegnati a risolverli correttamente e raggiungi il grado più alto possibile.'; + + @override + String get helpDialogTimeFrenzy => + '- Risolvi il maggior numero di problemi possibile in 3 minuti.\n\n- I problemi diventano via via più difficili.\n\n- Se fai 3 errori, sei fuori.'; + + @override + String get hideTask => 'Rimuovi dagli errori'; + + @override + String get home => 'Home'; + + @override + String get komi => 'Komi'; + + @override + String get language => 'Lingua'; + + @override + String get leave => 'Abbandona'; + + @override + String get light => 'Chiaro'; + + @override + String get login => 'Login'; + + @override + String get logout => 'Logout'; + + @override + String get long => 'Lungo'; + + @override + String mMinutes(int m) { + return '${m}min'; + } + + @override + String get maxNumberOfMistakes => 'Numero massimo di errori'; + + @override + String get maxRank => 'Livello massimo'; + + @override + String get medium => 'Medio'; + + @override + String get minRank => 'Livello minimo'; + + @override + String get minutes => 'Minuti'; + + @override + String get month => 'Mese'; + + @override + String get msgCannotUseAIRefereeYet => + 'È troppo presto per ricorrere all\'arbitro AI'; + + @override + String get msgCannotUseForcedCountingYet => + 'È troppo presto per forzare il conteggio'; + + @override + String get msgConfirmDeleteCollectionProgress => + 'Vuoi davvero cancellare il precedente tentativo?'; + + @override + String get msgConfirmResignation => 'Vuoi davvero abbandonare?'; + + @override + String msgConfirmStopEvent(String event) { + return 'Vuoi interrompere la prova?'; + } + + @override + String get msgDownloadingGame => 'Scaricamento partita'; + + @override + String msgGameSavedTo(String path) { + return 'Partita salvata in $path'; + } + + @override + String get msgPleaseWaitForYourTurn => 'Per favore, attendi il tuo turno'; + + @override + String get msgSearchingForGame => 'Cerco una partita...'; + + @override + String get msgSgfCopied => 'SGF copiato'; + + @override + String get msgTaskLinkCopied => 'Link copiato'; + + @override + String get msgWaitingForOpponentsDecision => + 'In attesa che l\'avversario decida...'; + + @override + String get msgYouCannotPass => 'Non puoi passare'; + + @override + String get msgYourOpponentDisagreesWithCountingResult => + 'L\'avversario non è d\'accordo col risultato'; + + @override + String get msgYourOpponentRefusesToCount => + 'L\'avversario ha rifiutato il conteggio'; + + @override + String get msgYourOpponentRequestsAutomaticCounting => + 'L\'avversario ha richiesto il conteggio automatico. Sei d\'accordo?'; + + @override + String get myGames => 'Le mie partite'; + + @override + String get myMistakes => 'I miei errori'; + + @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 problemi', + one: '1 problema', + zero: 'Nessun problema', + ); + 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 problemi disponibili', + one: '1 problema disponibile', + zero: 'Problemi non disponibili', + ); + return '$_temp0'; + } + + @override + String get newBestResult => 'Nuovo record!'; + + @override + String get no => 'No'; + + @override + String get none => 'Nessuna'; + + @override + String get numberOfTasks => 'Numero di problemi'; + + @override + String nxnBoardSize(int n) { + return '$n×$n'; + } + + @override + String get ogsDesc => + 'Un server internazionale, più popolare in Europa e nelle Americhe.'; + + @override + String get ogsName => 'Online Go Server'; + + @override + String get ok => 'OK'; + + @override + String get pass => 'Passa'; + + @override + String get password => 'Password'; + + @override + String get play => 'Gioca'; + + @override + String get pleaseMarkDeadStones => 'Indica le pietre catturate.'; + + @override + String get promotionRequirements => 'Requisiti per la promozione'; + + @override + String pxsByoyomi(int p, int s) { + return '$p×${s}s'; + } + + @override + String get rank => 'Livello'; + + @override + String get rankedMode => 'Modalità classificata'; + + @override + String get recentRecord => 'Risultato recente'; + + @override + String get register => 'Registrati'; + + @override + String get rejectDeadStones => 'Rifiuta pietre catturate'; + + @override + String get resign => 'Abbandona'; + + @override + String get responseDelay => 'Ritarda la risposta'; + + @override + String get responseDelayDesc => + 'Imposta un tempo di attesa prima di mostrare la risposta di un problema'; + + @override + String get responseDelayLong => 'Lungo'; + + @override + String get responseDelayMedium => 'Medio'; + + @override + String get responseDelayNone => 'Nessuno'; + + @override + String get responseDelayShort => 'Breve'; + + @override + String get result => 'Risultato'; + + @override + String get resultAccept => 'Accetta'; + + @override + String get resultReject => 'Rifiuta'; + + @override + String get rules => 'Regole'; + + @override + String get rulesChinese => 'Cinesi'; + + @override + String get rulesJapanese => 'Giapponesi'; + + @override + String get rulesKorean => 'Coreane'; + + @override + String sSeconds(int s) { + return '${s}s'; + } + + @override + String get save => 'Salva'; + + @override + String get saveSGF => 'Salva SGF'; + + @override + String get seconds => 'Secondi'; + + @override + String get settings => 'Impostazioni'; + + @override + String get short => 'Corta'; + + @override + String get showCoordinates => 'Coordinate'; + + @override + String get showMoveErrorsAsCrosses => 'Usa le croci per le mosse sbagliate'; + + @override + String get showMoveErrorsAsCrossesDesc => + 'Indica le mosse sbagliate con una X rossa invece di un pallino'; + + @override + String get simple => 'Sottile'; + + @override + String get sortModeDifficult => 'Difficile'; + + @override + String get sortModeRecent => 'Recente'; + + @override + String get sound => 'Suono'; + + @override + String get start => 'Inizia'; + + @override + String get statistics => 'Statistiche'; + + @override + String get statsDateColumn => 'Data'; + + @override + String get statsDurationColumn => 'Durata'; + + @override + String get statsTimeColumn => 'Tempo'; + + @override + String get stoneShadows => 'Ombre'; + + @override + String get stones => 'Pietre'; + + @override + String get subtopic => 'Sottoargomento'; + + @override + String get system => 'Sistema'; + + @override + String get task => 'Problema'; + + @override + String get taskCorrect => 'Corretto'; + + @override + String get taskNext => 'Prossimo'; + + @override + String get taskNotFound => 'Problema non trovato'; + + @override + String get taskRedo => 'Ritenta'; + + @override + String get taskSource => 'Fonte dei problemi'; + + @override + String get taskSourceFromMyMistakes => 'I miei errori'; + + @override + String get taskSourceFromTaskTopic => 'Argomento'; + + @override + String get taskSourceFromTaskTypes => 'Tipologia'; + + @override + String get taskTag_afterJoseki => 'Dopo il joseki'; + + @override + String get taskTag_aiOpening => 'Aperture AI'; + + @override + String get taskTag_aiVariations => 'Varianti AI'; + + @override + String get taskTag_attack => 'Attaccare'; + + @override + String get taskTag_attackAndDefenseInKo => 'Attacco e difesa nel ko'; + + @override + String get taskTag_attackAndDefenseOfCuts => 'Attacco e difesa dei tagli'; + + @override + String get taskTag_attackAndDefenseOfInvadingStones => + 'Attacco e difesa nelle invasioni'; + + @override + String get taskTag_avoidKo => 'Evita il ko'; + + @override + String get taskTag_avoidMakingDeadShape => 'Evita di creare una forma morta'; + + @override + String get taskTag_avoidTrap => 'Evita le trappole'; + + @override + String get taskTag_basicEndgame => 'Fine gioco: base'; + + @override + String get taskTag_basicLifeAndDeath => 'Vita e morte: base'; + + @override + String get taskTag_basicMoves => 'Movimenti di base'; + + @override + String get taskTag_basicTesuji => 'Tesuji'; + + @override + String get taskTag_beginner => 'Principiante'; + + @override + String get taskTag_bend => 'Piega (bend)'; + + @override + String get taskTag_bentFour => 'Quattro piegato (bent four)'; + + @override + String get taskTag_bentFourInTheCorner => 'Quattro piegato nell\'angolo'; + + @override + String get taskTag_bentThree => 'Tre piegato (bent three)'; + + @override + String get taskTag_bigEyeLiberties => 'Libertà degli occhi grandi'; + + @override + String get taskTag_bigEyeVsSmallEye => 'Occhio grande vs. occhio piccolo'; + + @override + String get taskTag_bigPoints => 'Punti grandi'; + + @override + String get taskTag_blindSpot => 'Punto cieco'; + + @override + String get taskTag_breakEye => 'Distruggi l\'occhio'; + + @override + String get taskTag_breakEyeInOneStep => 'Distruggi l\'occhio in un passo'; + + @override + String get taskTag_breakEyeInSente => 'Distruggi l\'occhio in sente'; + + @override + String get taskTag_breakOut => 'Evadi'; + + @override + String get taskTag_breakPoints => 'Togli punti'; + + @override + String get taskTag_breakShape => 'Rovina la forma'; + + @override + String get taskTag_bridgeUnder => 'Ponte (watari)'; + + @override + String get taskTag_brilliantSequence => 'Sequenze brillanti'; + + @override + String get taskTag_bulkyFive => 'Cinque a manico di coltello (bulky five)'; + + @override + String get taskTag_bump => 'Bump'; + + @override + String get taskTag_captureBySnapback => 'Cattura con snapback'; + + @override + String get taskTag_captureInLadder => 'Cattura in scala'; + + @override + String get taskTag_captureInOneMove => 'Cattura in una mossa'; + + @override + String get taskTag_captureOnTheSide => 'Cattura sul lato'; + + @override + String get taskTag_captureToLive => 'Cattura per vivere'; + + @override + String get taskTag_captureTwoRecaptureOne => 'Cattura due, ricattura una'; + + @override + String get taskTag_capturingRace => 'Semeai (capturing race)'; + + @override + String get taskTag_capturingTechniques => 'Tecniche di cattura'; + + @override + String get taskTag_carpentersSquareAndSimilar => + 'Quadrato del carpentiere e simili'; + + @override + String get taskTag_chooseTheFight => 'Scegli il combattimento'; + + @override + String get taskTag_clamp => 'Morsa (clamp)'; + + @override + String get taskTag_clampCapture => 'Cattura con la morsa'; + + @override + String get taskTag_closeInCapture => 'Cattura progressiva'; + + @override + String get taskTag_combination => 'Combinazione'; + + @override + String get taskTag_commonLifeAndDeath => 'Vita e morte: forme tipiche'; + + @override + String get taskTag_compareSize => 'Confronta la dimensione'; + + @override + String get taskTag_compareValue => 'Confronta il valore'; + + @override + String get taskTag_completeKoToSecureEndgameAdvantage => + 'Completa il ko nel fine gioco'; + + @override + String get taskTag_compositeProblems => 'Problemi compositi'; + + @override + String get taskTag_comprehensiveTasks => 'Problemi completi'; + + @override + String get taskTag_connect => 'Connetti'; + + @override + String get taskTag_connectAndDie => 'Connetti e muori (oiotoshi)'; + + @override + String get taskTag_connectInOneMove => 'Connetti in una mossa'; + + @override + String get taskTag_contactFightTesuji => 'Tesuji di combattimento a contatto'; + + @override + String get taskTag_contactPlay => 'Giocare a contatto (tsuke)'; + + @override + String get taskTag_corner => 'Angolo'; + + @override + String get taskTag_cornerIsGoldSideIsSilverCenterIsGrass => + 'L\'angolo è oro, il lato è argento, il centro è erba'; + + @override + String get taskTag_counter => 'Resisti'; + + @override + String get taskTag_counterAttack => 'Contrattacca'; + + @override + String get taskTag_cranesNest => 'Nido di gru'; + + @override + String get taskTag_crawl => 'Striscia in seconda linea (crawl)'; + + @override + String get taskTag_createShortageOfLiberties => 'Crea mancanza di libertà'; + + @override + String get taskTag_crossedFive => 'Cinque a croce (crossed five)'; + + @override + String get taskTag_cut => 'Taglio'; + + @override + String get taskTag_cut2 => 'Taglio'; + + @override + String get taskTag_cutAcross => 'Taglia attraverso il keima'; + + @override + String get taskTag_defendFromInvasion => 'Difenditi da un\'invasione'; + + @override + String get taskTag_defendPoints => 'Difendi il punteggio'; + + @override + String get taskTag_defendWeakPoint => 'Difendi i punti deboli'; + + @override + String get taskTag_descent => 'Discesa'; + + @override + String get taskTag_diagonal => 'Diagonale'; + + @override + String get taskTag_directionOfCapture => 'Direzione di cattura'; + + @override + String get taskTag_directionOfEscape => 'Direzione di fuga'; + + @override + String get taskTag_directionOfPlay => 'Direzione di gioco'; + + @override + String get taskTag_doNotUnderestimateOpponent => + 'Non sottostimare l\'avversario'; + + @override + String get taskTag_doubleAtari => 'Doppio atari'; + + @override + String get taskTag_doubleCapture => 'Doppia cattura'; + + @override + String get taskTag_doubleKo => 'Doppio ko'; + + @override + String get taskTag_doubleSenteEndgame => 'Fine gioco in doppio sente'; + + @override + String get taskTag_doubleSnapback => 'Doppio snapback'; + + @override + String get taskTag_endgame => 'Fine gioco: generico'; + + @override + String get taskTag_endgameFundamentals => 'Fondamenti di fine gioco'; + + @override + String get taskTag_endgameIn5x5 => 'Fine gioco sul 5x5'; + + @override + String get taskTag_endgameOn4x4 => 'Fine gioco sul 4x4'; + + @override + String get taskTag_endgameTesuji => 'Tesuji di fine gioco'; + + @override + String get taskTag_engulfingAtari => 'Atari per accerchiamento'; + + @override + String get taskTag_escape => 'Fuggi'; + + @override + String get taskTag_escapeInOneMove => 'Fuggi in una mossa'; + + @override + String get taskTag_exploitShapeWeakness => 'Sfrutta le debolezze'; + + @override + String get taskTag_eyeVsNoEye => 'Meari menashi (eye vs. no-eye)'; + + @override + String get taskTag_fillNeutralPoints => 'Riempi i dame'; + + @override + String get taskTag_findTheRoot => 'Trova la radice'; + + @override + String get taskTag_firstLineBrilliantMove => 'Tesuji in prima linea'; + + @override + String get taskTag_flowerSix => 'Sei a grappolo d\'uva (rabbity six)'; + + @override + String get taskTag_goldenChickenStandingOnOneLeg => + 'Il gallo d\'oro sta su una zampa'; + + @override + String get taskTag_groupLiberties => 'Libertà dei gruppi'; + + @override + String get taskTag_groupsBase => 'Base del gruppo'; + + @override + String get taskTag_hane => 'Hane'; + + @override + String get taskTag_increaseEyeSpace => 'Aumenta lo spazio vitale'; + + @override + String get taskTag_increaseLiberties => 'Aumenta le libertà'; + + @override + String get taskTag_indirectAttack => 'Attacco indiretto'; + + @override + String get taskTag_influenceKeyPoints => 'Punti chiave dell\'influenza'; + + @override + String get taskTag_insideKill => 'Uccidi dall\'interno'; + + @override + String get taskTag_insideMoves => 'Mosse interne'; + + @override + String get taskTag_interestingTasks => 'Problemi interessanti'; + + @override + String get taskTag_internalLibertyShortage => 'Carenza di libertà interne'; + + @override + String get taskTag_invadingTechnique => 'Tecniche di invasione'; + + @override + String get taskTag_invasion => 'Invasione'; + + @override + String get taskTag_jGroupAndSimilar => 'Gruppo J e simili'; + + @override + String get taskTag_josekiFundamentals => 'Fondamenti di joseki'; + + @override + String get taskTag_jump => 'Salto'; + + @override + String get taskTag_keepSente => 'Mantieni il sente'; + + @override + String get taskTag_killAfterCapture => 'Uccidi dopo la cattura'; + + @override + String get taskTag_killByEyePointPlacement => + 'Uccidi giocando nel punto vitale'; + + @override + String get taskTag_knightsMove => 'Mossa del cavallo (keima)'; + + @override + String get taskTag_ko => 'Ko'; + + @override + String get taskTag_kosumiWedge => 'Taglia il kosumi (atekomi)'; + + @override + String get taskTag_largeKnightsMove => 'Mossa del cavallo grande (ogeima)'; + + @override + String get taskTag_largeMoyoFight => 'Combattimento nel moyo grande'; + + @override + String get taskTag_lifeAndDeath => 'Vita e morte: generale'; + + @override + String get taskTag_lifeAndDeathOn4x4 => 'Vita e morte sul 4x4'; + + @override + String get taskTag_lookForLeverage => 'Sfrutta le forzanti'; + + @override + String get taskTag_looseLadder => 'Scala lasca'; + + @override + String get taskTag_lovesickCut => 'Taglio degli innamorati'; + + @override + String get taskTag_makeEye => 'Fai un occhio'; + + @override + String get taskTag_makeEyeInOneStep => 'Fai un occhio in un passo'; + + @override + String get taskTag_makeEyeInSente => 'Fai un occhio in sente'; + + @override + String get taskTag_makeKo => 'Fai ko'; + + @override + String get taskTag_makeShape => 'Fai forma'; + + @override + String get taskTag_middlegame => 'Mediogioco (chuban)'; + + @override + String get taskTag_monkeyClimbingMountain => 'La scimmia scala la montagna'; + + @override + String get taskTag_mouseStealingOil => 'Il topo ruba l\'olio'; + + @override + String get taskTag_moveOut => 'Scappa'; + + @override + String get taskTag_moveTowardsEmptySpace => 'Muovi verso lo spazio vuoto'; + + @override + String get taskTag_multipleBrilliantMoves => 'Tesuji multipli'; + + @override + String get taskTag_net => 'Rete (geta)'; + + @override + String get taskTag_netCapture => 'Cattura con rete'; + + @override + String get taskTag_observeSubtleDifference => 'Osserva le piccole differenze'; + + @override + String get taskTag_occupyEncloseAndApproachCorner => + 'Occupa, circonda e approccia gli angoli'; + + @override + String get taskTag_oneStoneTwoPurposes => 'Una pietra, due scopi'; + + @override + String get taskTag_opening => 'Apertura (fuseki)'; + + @override + String get taskTag_openingChoice => 'Scegli l\'apertura'; + + @override + String get taskTag_openingFundamentals => 'Fondamenti delle aperture'; + + @override + String get taskTag_orderOfEndgameMoves => 'Ordine delle mosse nel fine gioco'; + + @override + String get taskTag_orderOfMoves => 'Ordine delle mosse'; + + @override + String get taskTag_orderOfMovesInKo => 'Ordine delle mosse nel ko'; + + @override + String get taskTag_orioleCapturesButterfly => 'L\'oriolo cattura la farfalla'; + + @override + String get taskTag_pincer => 'Pinza'; + + @override + String get taskTag_placement => 'Oki (placement)'; + + @override + String get taskTag_plunderingTechnique => 'Tecniche di saccheggio'; + + @override + String get taskTag_preventBambooJoint => 'Previeni il bamboo joint'; + + @override + String get taskTag_preventBridgingUnder => 'Impedisci il ponte (watari)'; + + @override + String get taskTag_preventOpponentFromApproaching => + 'Previeni l\'approccio dell\'avversario'; + + @override + String get taskTag_probe => 'Sonda (probe)'; + + @override + String get taskTag_profitInSente => 'Approfitta del sente'; + + @override + String get taskTag_profitUsingLifeAndDeath => 'Approfitta della vita-e-morte'; + + @override + String get taskTag_push => 'Spinta'; + + @override + String get taskTag_pyramidFour => 'Quattro a forma di T'; + + @override + String get taskTag_realEyeAndFalseEye => 'Occhio vero vs. occhio falso'; + + @override + String get taskTag_rectangularSix => 'Sei a forma di rettangolo'; + + @override + String get taskTag_reduceEyeSpace => 'Riduci lo spazio vitale'; + + @override + String get taskTag_reduceLiberties => 'Riduci le libertà'; + + @override + String get taskTag_reduction => 'Riduzione'; + + @override + String get taskTag_runWeakGroup => 'Gestisci il gruppo debole'; + + @override + String get taskTag_sabakiAndUtilizingInfluence => + 'Sabaki e utilizzo dell\'influenza'; + + @override + String get taskTag_sacrifice => 'Sacrificio'; + + @override + String get taskTag_sacrificeAndSqueeze => 'Sacrificio e squeeze'; + + @override + String get taskTag_sealIn => 'Sigilla'; + + @override + String get taskTag_secondLine => 'Seconda linea'; + + @override + String get taskTag_seizeTheOpportunity => 'Cogli l\'attimo'; + + @override + String get taskTag_seki => 'Seki'; + + @override + String get taskTag_senteAndGote => 'Sente e gote'; + + @override + String get taskTag_settleShape => 'Stabilizza la forma'; + + @override + String get taskTag_settleShapeInSente => 'Stabilizza la forma in sente'; + + @override + String get taskTag_shape => 'Forma'; + + @override + String get taskTag_shapesVitalPoint => 'Punto vitale della forma'; + + @override + String get taskTag_side => 'Lato'; + + @override + String get taskTag_smallBoardEndgame => 'Fine gioco su goban piccolo'; + + @override + String get taskTag_snapback => 'Snapback'; + + @override + String get taskTag_solidConnection => 'Connessione solida'; + + @override + String get taskTag_solidExtension => 'Estensione solida'; + + @override + String get taskTag_splitInOneMove => 'Separa in una mossa'; + + @override + String get taskTag_splittingMove => 'Mosse per separare'; + + @override + String get taskTag_squareFour => 'Quattro a forma di quadrato'; + + @override + String get taskTag_squeeze => 'Squeeze'; + + @override + String get taskTag_standardCapturingRaces => 'Semeai standard'; + + @override + String get taskTag_standardCornerAndSideEndgame => + 'Fine gioco standard (angoli e lati)'; + + @override + String get taskTag_straightFour => 'Quattro in fila'; + + @override + String get taskTag_straightThree => 'Tre in fila'; + + @override + String get taskTag_surroundTerritory => 'Circonda il territorio'; + + @override + String get taskTag_symmetricShape => 'Forme simmetriche'; + + @override + String get taskTag_techniqueForReinforcingGroups => + 'Tecniche per rafforzare i gruppi'; + + @override + String get taskTag_techniqueForSecuringTerritory => + 'Tecniche per mettere in sicurezza un territorio'; + + @override + String get taskTag_textbookTasks => 'Problemi da libro'; + + @override + String get taskTag_thirdAndFourthLine => 'Terza e quarta linea'; + + @override + String get taskTag_threeEyesTwoActions => 'Tre occhi, due azioni'; + + @override + String get taskTag_threeSpaceExtensionFromTwoStones => + 'Estensione di tre spazi da due pietre'; + + @override + String get taskTag_throwIn => 'Throw-in'; + + @override + String get taskTag_tigersMouth => 'Bocca di tigre'; + + @override + String get taskTag_tombstoneSqueeze => 'Tombstone squeeze (Pagoda squeeze)'; + + @override + String get taskTag_tripodGroupWithExtraLegAndSimilar => + 'Tripode con gamba extra e simili'; + + @override + String get taskTag_twoHaneGainOneLiberty => + 'Il doppio hane aggiunge una libertà'; + + @override + String get taskTag_twoHeadedDragon => 'Drago a due teste'; + + @override + String get taskTag_twoSpaceExtension => 'Estensione a due spazi'; + + @override + String get taskTag_typesOfKo => 'Tipi di ko'; + + @override + String get taskTag_underTheStones => 'Ishi no shita (under the stones)'; + + @override + String get taskTag_underneathAttachment => 'Tsuke da sotto'; + + @override + String get taskTag_urgentPointOfAFight => + 'Punti urgenti per il combattimento'; + + @override + String get taskTag_urgentPoints => 'Punti urgenti'; + + @override + String get taskTag_useConnectAndDie => 'Sfrutta oiotoshi (connect and die)'; + + @override + String get taskTag_useCornerSpecialProperties => + 'Usa le peculiarità dell\'angolo'; + + @override + String get taskTag_useDescentToFirstLine => 'Usa la discesa in prima linea'; + + @override + String get taskTag_useInfluence => 'Usa l\'influenza'; + + @override + String get taskTag_useOpponentsLifeAndDeath => + 'Sfrutta la vita-e-morte dell\'avversario'; + + @override + String get taskTag_useShortageOfLiberties => 'Sfrutta la carenza di libertà'; + + @override + String get taskTag_useSnapback => 'Usa lo snapback'; + + @override + String get taskTag_useSurroundingStones => 'Usa le pietre attorno'; + + @override + String get taskTag_vitalAndUselessStones => 'Pietre vitali e pietre inutili'; + + @override + String get taskTag_vitalPointForBothSides => 'Punti vitali per entrambi'; + + @override + String get taskTag_vitalPointForCapturingRace => 'Punti vitali per il semeai'; + + @override + String get taskTag_vitalPointForIncreasingLiberties => + 'Punti vitali per aumentare le libertà'; + + @override + String get taskTag_vitalPointForKill => 'Punti vitali per uccidere'; + + @override + String get taskTag_vitalPointForLife => 'Punti vitali per vivere'; + + @override + String get taskTag_vitalPointForReducingLiberties => + 'Punti vitali per ridurre le libertà'; + + @override + String get taskTag_wedge => 'Wedge'; + + @override + String get taskTag_wedgingCapture => 'Cattura con wedge'; + + @override + String get taskTimeout => 'Timeout'; + + @override + String get taskTypeAppreciation => 'Valutazione'; + + @override + String get taskTypeCapture => 'Cattura'; + + @override + String get taskTypeCaptureRace => 'Semeai (capturing race)'; + + @override + String get taskTypeEndgame => 'Yose (fine gioco)'; + + @override + String get taskTypeJoseki => 'Joseki'; + + @override + String get taskTypeLifeAndDeath => 'Life & death'; + + @override + String get taskTypeMiddlegame => 'Chuban (mediogioco)'; + + @override + String get taskTypeOpening => 'Fuseki (apertura)'; + + @override + String get taskTypeTesuji => 'Tesuji'; + + @override + String get taskTypeTheory => 'Teoria'; + + @override + String get taskWrong => 'Sbagliato'; + + @override + String get tasksSolved => 'Problema risolto'; + + @override + String get test => 'Test'; + + @override + String get theme => 'Tema'; + + @override + String get thick => 'Spesso'; + + @override + String get timeFrenzy => 'Frenesia'; + + @override + String get timeFrenzyMistakes => 'Ricorda gli errori durante la Frenesia'; + + @override + String get timeFrenzyMistakesDesc => + 'Abilita il salvataggio degli errori commessi durante le sessioni di Frenesia'; + + @override + String get randomizeTaskOrientation => 'Orientamento casuale'; + + @override + String get randomizeTaskOrientationDesc => + 'Ruota e rifletti casualmente i problemi lungo gli assi orizzontale, verticale e diagonale per prevenire la memorizzazione e migliorare il riconoscimento dei pattern.'; + + @override + String get timePerTask => 'Tempo problema'; + + @override + String get today => 'Oggi'; + + @override + String get tooltipAnalyzeWithAISensei => 'Analizza con AI Sensei'; + + @override + String get tooltipDownloadGame => 'Scarica'; + + @override + String get topic => 'Argomento'; + + @override + String get topicExam => 'Argomento del test'; + + @override + String get topics => 'Argomenti'; + + @override + String get train => 'Allenamento'; + + @override + String get trainingAvgTimePerTask => 'Tempo medio problema'; + + @override + String get trainingFailed => 'Fallito'; + + @override + String get trainingMistakes => 'Errori'; + + @override + String get trainingPassed => 'Superato'; + + @override + String get trainingTotalTime => 'Tempo totale'; + + @override + String get tryCustomMoves => 'Prova altre mosse'; + + @override + String get tygemDesc => + 'Il server più popolare in Corea e uno dei più popolari al mondo.'; + + @override + String get tygemName => 'Tygem Baduk'; + + @override + String get type => 'Tipo'; + + @override + String get ui => 'Interfaccia utente'; + + @override + String get userInfo => 'Info utente'; + + @override + String get username => 'Username'; + + @override + String get voice => 'Voce'; + + @override + String get week => 'Settimana'; + + @override + String get white => 'Bianco'; + + @override + String get yes => 'Si'; +} diff --git a/lib/l10n/app_localizations_ro.dart b/lib/l10n/app_localizations_ro.dart new file mode 100644 index 00000000..12456848 --- /dev/null +++ b/lib/l10n/app_localizations_ro.dart @@ -0,0 +1,1424 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Romanian Moldavian Moldovan (`ro`). +class AppLocalizationsRo extends AppLocalizations { + AppLocalizationsRo([String locale = 'ro']) : super(locale); + + @override + String get about => 'Despre'; + + @override + String get acceptDeadStones => 'Acceptă pietrele moarte'; + + @override + String get accuracy => 'Precizie'; + + @override + String get aiReferee => 'Arbitru AI'; + + @override + String get aiSensei => 'AI Sensei'; + + @override + String get alwaysBlackToPlay => 'Întotdeauna negru la mutare'; + + @override + String get alwaysBlackToPlayDesc => + 'Setează toate sarcinile ca fiind negru la mutare pentru a evita confuzia'; + + @override + String get appearance => 'Aspect'; + + @override + String get autoCounting => 'Numărare automată'; + + @override + String get autoMatch => 'Potrivire automată'; + + @override + String get behaviour => 'Comportament'; + + @override + String get bestResult => 'Cel mai bun rezultat'; + + @override + String get black => 'Negru'; + + @override + String get board => 'Tabelă'; + + @override + String get boardSize => 'Dimensiunea tablei'; + + @override + String get boardTheme => 'Temă a tablei'; + + @override + String get byRank => 'După rang'; + + @override + String get cancel => 'Anulează'; + + @override + String get captures => 'Capturi'; + + @override + String get clearBoard => 'Curăță tabla'; + + @override + String get collectStats => 'Colectează statistici'; + + @override + String get collections => 'Colecții'; + + @override + String get confirm => 'Confirmă'; + + @override + String get confirmBoardSize => 'Confirmă dimensiunea tablei'; + + @override + String get confirmBoardSizeDesc => + 'Tablele de această dimensiune sau mai mari necesită confirmarea mutărilor'; + + @override + String get confirmMoves => 'Confirmă mutările'; + + @override + String get confirmMovesDesc => + 'Atinge de două ori pentru a confirma mutările pe table mari pentru a evita greșelile'; + + @override + String get continue_ => 'Continuă'; + + @override + String get copySGF => 'Copiază SGF'; + + @override + String get copyTaskLink => 'Copiază linkul sarcinii'; + + @override + String get customExam => 'Examen personalizat'; + + @override + String get dark => 'Întunecat'; + + @override + String get dontShowAgain => 'Nu mai arăta din nou'; + + @override + String get download => 'Descarcă'; + + @override + String get edgeLine => 'Linie de margine'; + + @override + String get empty => 'Gol'; + + @override + String get endgameExam => 'Examen de sfirsitul jocului'; + + @override + String get enterTaskLink => 'Introdu linkul sarcinii'; + + @override + String get errCannotBeEmpty => 'Nu poate fi gol'; + + @override + String get errFailedToDownloadGame => 'Descărcarea jocului a eșuat'; + + @override + String get errFailedToLoadGameList => + 'Încărcarea listei de jocuri a eșuat. Te rugăm să încerci din nou.'; + + @override + String get errFailedToUploadGameToAISensei => + 'Încărcarea jocului în AI Sensei a eșuat'; + + @override + String get errIncorrectUsernameOrPassword => + 'Nume de utilizator sau parolă incorecte'; + + @override + String errMustBeAtLeast(num n) { + return 'Trebuie să fie cel puțin $n'; + } + + @override + String errMustBeAtMost(num n) { + return 'Trebuie să fie cel mult $n'; + } + + @override + String get errMustBeInteger => 'Trebuie să fie un număr întreg'; + + @override + String get exit => 'Ieșire'; + + @override + String get exitTryMode => 'Ieși din modul de încercare'; + + @override + String get find => 'Caută'; + + @override + String get findTask => 'Caută sarcina'; + + @override + String get findTaskByLink => 'După link'; + + @override + String get findTaskByPattern => 'După tipar'; + + @override + String get findTaskResults => 'Rezultatele căutării'; + + @override + String get findTaskSearching => 'Se caută...'; + + @override + String get forceCounting => 'Forțează numărarea'; + + @override + String get foxwqDesc => 'Cel mai popular server din China și din lume.'; + + @override + String get foxwqName => 'Fox Weiqi'; + + @override + String get gameInfo => 'Informații despre joc'; + + @override + String get gameRecord => 'Înregistrare joc'; + + @override + String get gradingExam => 'Examen de clasificare'; + + @override + String get handicap => 'Handicap'; + + @override + String get help => 'Ajutor'; + + @override + String get helpDialogCollections => + 'Colecțiile sunt seturi clasice, selectate cu grijă, de sarcini de înaltă calitate care au o valoare specială ca resursă de antrenament.\n\nObiectivul principal este să rezolvi o colecție cu o rată mare de succes. Un obiectiv secundar este să o rezolvi cât mai repede posibil.\n\nPentru a începe sau a continua rezolvarea unei colecții, glisează spre stânga pe elementul colecției în modul portret sau apasă butoanele Start/Continuă în modul peisaj.'; + + @override + String get helpDialogEndgameExam => + '- Examenele de final sunt seturi de 10 sarcini de final și ai 45 de secunde pentru fiecare.\n\n- Promovezi examenul dacă rezolvi corect 8 sau mai multe (rata de succes 80%).\n\n- Promovarea examenului pentru un anumit rang deblochează examenul pentru următorul rang.'; + + @override + String get helpDialogGradingExam => + '- Examenele de clasificare sunt seturi de 10 sarcini și ai 45 de secunde pentru fiecare.\n\n- Promovezi examenul dacă rezolvi corect 8 sau mai multe (rata de succes 80%).\n\n- Promovarea examenului pentru un anumit rang deblochează examenul pentru următorul rang.'; + + @override + String get helpDialogRankedMode => + '- Rezolvă sarcini fără limită de timp.\n\n- Dificultatea sarcinilor crește în funcție de cât de repede le rezolvi.\n\n- Concentrează-te pe a rezolva corect și atinge cel mai înalt rang posibil.'; + + @override + String get helpDialogTimeFrenzy => + '- Ai 3 minute pentru a rezolva cât mai multe sarcini posibil.\n\n- Sarcinile devin tot mai dificile pe măsură ce le rezolvi.\n\n- Dacă faci 3 greșeli, ești eliminat.'; + + @override + String get hideTask => 'Elimină din greșeli'; + + @override + String get home => 'Acasă'; + + @override + String get komi => 'Komi'; + + @override + String get language => 'Limbă'; + + @override + String get leave => 'Părăsește'; + + @override + String get light => 'Luminos'; + + @override + String get login => 'Autentificare'; + + @override + String get logout => 'Deconectare'; + + @override + String get long => 'Lung'; + + @override + String mMinutes(int m) { + return '${m}min'; + } + + @override + String get maxNumberOfMistakes => 'Număr maxim de greșeli'; + + @override + String get maxRank => 'Rang maxim'; + + @override + String get medium => 'Mediu'; + + @override + String get minRank => 'Rang minim'; + + @override + String get minutes => 'Minute'; + + @override + String get month => 'Lună'; + + @override + String get msgCannotUseAIRefereeYet => 'Arbitrul AI nu poate fi folosit încă'; + + @override + String get msgCannotUseForcedCountingYet => + 'Numărarea forțată nu poate fi folosită încă'; + + @override + String get msgConfirmDeleteCollectionProgress => + 'Ești sigur că vrei să ștergi încercarea anterioară?'; + + @override + String get msgConfirmResignation => 'Ești sigur că vrei să renunți?'; + + @override + String msgConfirmStopEvent(String event) { + return 'Ești sigur că vrei să oprești $event?'; + } + + @override + String get msgDownloadingGame => 'Se descarcă jocul'; + + @override + String msgGameSavedTo(String path) { + return 'Joc salvat în $path'; + } + + @override + String get msgPleaseWaitForYourTurn => 'Te rog, așteaptă-ți rândul'; + + @override + String get msgSearchingForGame => 'Se caută un joc...'; + + @override + String get msgSgfCopied => 'SGF copiat în clipboard'; + + @override + String get msgTaskLinkCopied => 'Linkul sarcinii a fost copiat.'; + + @override + String get msgWaitingForOpponentsDecision => + 'Se așteaptă decizia adversarului...'; + + @override + String get msgYouCannotPass => 'Nu poți pasa'; + + @override + String get msgYourOpponentDisagreesWithCountingResult => + 'Adversarul tău nu este de acord cu rezultatul numărării'; + + @override + String get msgYourOpponentRefusesToCount => 'Adversarul tău refuză să numere'; + + @override + String get msgYourOpponentRequestsAutomaticCounting => + 'Adversarul tău solicită numărarea automată. Ești de acord?'; + + @override + String get myGames => 'Jocurile mele'; + + @override + String get myMistakes => 'Greșelile mele'; + + @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 sarcini', + one: '1 sarcină', + zero: 'Nicio sarcină', + ); + 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 sarcini disponibile', + one: '1 sarcină disponibilă', + zero: 'Nicio sarcină disponibilă', + ); + return '$_temp0'; + } + + @override + String get newBestResult => 'Cel mai bun rezultat nou!'; + + @override + String get no => 'Nu'; + + @override + String get none => 'Niciuna'; + + @override + String get numberOfTasks => 'Număr de sarcini'; + + @override + String nxnBoardSize(int n) { + return '$n×$n'; + } + + @override + String get ogsDesc => + 'Un server internațional, cel mai popular în Europa și Americi.'; + + @override + String get ogsName => 'Serverul Online Go'; + + @override + String get ok => 'OK'; + + @override + String get pass => 'Pasează'; + + @override + String get password => 'Parolă'; + + @override + String get play => 'Joacă'; + + @override + String get pleaseMarkDeadStones => 'Te rog marchează pietrele moarte.'; + + @override + String get promotionRequirements => 'Cerințe pentru promovare'; + + @override + String pxsByoyomi(int p, int s) { + return '$p×${s}s'; + } + + @override + String get rank => 'Rang'; + + @override + String get rankedMode => 'Mod clasament'; + + @override + String get recentRecord => 'Rezultate recente'; + + @override + String get register => 'Înregistrare'; + + @override + String get rejectDeadStones => 'Respinge pietrele moarte'; + + @override + String get resign => 'Renunță'; + + @override + String get responseDelay => 'Întârziere răspuns'; + + @override + String get responseDelayDesc => + 'Durata întârzierii înainte ca răspunsul să apară în timpul rezolvării sarcinilor'; + + @override + String get responseDelayLong => 'Lungă'; + + @override + String get responseDelayMedium => 'Medie'; + + @override + String get responseDelayNone => 'Fără'; + + @override + String get responseDelayShort => 'Scurtă'; + + @override + String get result => 'Rezultat'; + + @override + String get resultAccept => 'Acceptă'; + + @override + String get resultReject => 'Respinge'; + + @override + String get rules => 'Reguli'; + + @override + String get rulesChinese => 'Chineze'; + + @override + String get rulesJapanese => 'Japoneze'; + + @override + String get rulesKorean => 'Coreene'; + + @override + String sSeconds(int s) { + return '${s}s'; + } + + @override + String get save => 'Salvează'; + + @override + String get saveSGF => 'Salvează SGF'; + + @override + String get seconds => 'Secunde'; + + @override + String get settings => 'Setări'; + + @override + String get short => 'Scurt'; + + @override + String get showCoordinates => 'Afișează coordonatele'; + + @override + String get showMoveErrorsAsCrosses => 'Afișează mutările greșite ca cruci'; + + @override + String get showMoveErrorsAsCrossesDesc => + 'Afișează mutările greșite ca cruci roșii în loc de puncte roșii'; + + @override + String get simple => 'Simplu'; + + @override + String get sortModeDifficult => 'Dificil'; + + @override + String get sortModeRecent => 'Recent'; + + @override + String get sound => 'Sunet'; + + @override + String get start => 'Start'; + + @override + String get statistics => 'Statistici'; + + @override + String get statsDateColumn => 'Dată'; + + @override + String get statsDurationColumn => 'Durată'; + + @override + String get statsTimeColumn => 'Timp'; + + @override + String get stoneShadows => 'Umbrele pietrelor'; + + @override + String get stones => 'Pietre'; + + @override + String get subtopic => 'Subtemă'; + + @override + String get system => 'Sistem'; + + @override + String get task => 'Sarcină'; + + @override + String get taskCorrect => 'Corect'; + + @override + String get taskNext => 'Următorul'; + + @override + String get taskNotFound => 'Sarcina nu a fost găsită'; + + @override + String get taskRedo => 'Reia'; + + @override + String get taskSource => 'Sursă sarcină'; + + @override + String get taskSourceFromMyMistakes => 'Din greșelile mele'; + + @override + String get taskSourceFromTaskTopic => 'Din subiectul sarcinii'; + + @override + String get taskSourceFromTaskTypes => 'Din tipurile de sarcini'; + + @override + String get taskTag_afterJoseki => 'După joseki'; + + @override + String get taskTag_aiOpening => 'Deschidere AI'; + + @override + String get taskTag_aiVariations => 'Variații AI'; + + @override + String get taskTag_attack => 'Atac'; + + @override + String get taskTag_attackAndDefenseInKo => 'Atac și apărare într-un ko'; + + @override + String get taskTag_attackAndDefenseOfCuts => 'Atac și apărare la tăieturi'; + + @override + String get taskTag_attackAndDefenseOfInvadingStones => + 'Atac și apărare ale pietrelor invadatoare'; + + @override + String get taskTag_avoidKo => 'Evită ko-ul'; + + @override + String get taskTag_avoidMakingDeadShape => 'Evită formarea unei forme moarte'; + + @override + String get taskTag_avoidTrap => 'Evită capcana'; + + @override + String get taskTag_basicEndgame => 'Final: elementar'; + + @override + String get taskTag_basicLifeAndDeath => 'Viață și moarte: elementar'; + + @override + String get taskTag_basicMoves => 'Mutări de bază'; + + @override + String get taskTag_basicTesuji => 'Tesuji de bază'; + + @override + String get taskTag_beginner => 'Începător'; + + @override + String get taskTag_bend => 'Îndoire'; + + @override + String get taskTag_bentFour => 'Patru îndoite'; + + @override + String get taskTag_bentFourInTheCorner => 'Patru îndoite în colț'; + + @override + String get taskTag_bentThree => 'Trei îndoite'; + + @override + String get taskTag_bigEyeLiberties => 'Libertățile unui ochi mare'; + + @override + String get taskTag_bigEyeVsSmallEye => 'Ochi mare vs ochi mic'; + + @override + String get taskTag_bigPoints => 'Puncte mari'; + + @override + String get taskTag_blindSpot => 'Punct mort'; + + @override + String get taskTag_breakEye => 'Distruge ochiul'; + + @override + String get taskTag_breakEyeInOneStep => + 'Distruge ochiul dintr-o singură mutare'; + + @override + String get taskTag_breakEyeInSente => 'Distruge ochiul în sente'; + + @override + String get taskTag_breakOut => 'Evadează'; + + @override + String get taskTag_breakPoints => 'Distruge punctele'; + + @override + String get taskTag_breakShape => 'Distruge forma'; + + @override + String get taskTag_bridgeUnder => 'Pod dedesubt'; + + @override + String get taskTag_brilliantSequence => 'Secvență strălucită'; + + @override + String get taskTag_bulkyFive => 'Cinci voluminoase'; + + @override + String get taskTag_bump => 'Împinge'; + + @override + String get taskTag_captureBySnapback => 'Captură prin snapback'; + + @override + String get taskTag_captureInLadder => 'Captură în scară'; + + @override + String get taskTag_captureInOneMove => 'Captură într-o mutare'; + + @override + String get taskTag_captureOnTheSide => 'Captură pe margine'; + + @override + String get taskTag_captureToLive => 'Captură pentru a trăi'; + + @override + String get taskTag_captureTwoRecaptureOne => + 'Capturează două, recapturează una'; + + @override + String get taskTag_capturingRace => 'Cursă de capturare'; + + @override + String get taskTag_capturingTechniques => 'Tehnici de capturare'; + + @override + String get taskTag_carpentersSquareAndSimilar => + 'Pătratul tâmplarului și forme similare'; + + @override + String get taskTag_chooseTheFight => 'Alege lupta'; + + @override + String get taskTag_clamp => 'Prindere (clamp)'; + + @override + String get taskTag_clampCapture => 'Captură prin prindere'; + + @override + String get taskTag_closeInCapture => 'Captură prin închidere'; + + @override + String get taskTag_combination => 'Combinație'; + + @override + String get taskTag_commonLifeAndDeath => 'Viață și moarte: forme comune'; + + @override + String get taskTag_compareSize => 'Compară dimensiunea'; + + @override + String get taskTag_compareValue => 'Compară valoarea'; + + @override + String get taskTag_completeKoToSecureEndgameAdvantage => + 'Finalizează ko-ul pentru a asigura un avantaj în final'; + + @override + String get taskTag_compositeProblems => 'Sarcini compuse'; + + @override + String get taskTag_comprehensiveTasks => 'Sarcini cuprinzătoare'; + + @override + String get taskTag_connect => 'Conectează'; + + @override + String get taskTag_connectAndDie => 'Conectează și mori'; + + @override + String get taskTag_connectInOneMove => 'Conectează dintr-o mutare'; + + @override + String get taskTag_contactFightTesuji => 'Tesuji de luptă prin contact'; + + @override + String get taskTag_contactPlay => 'Mutare de contact'; + + @override + String get taskTag_corner => 'Colț'; + + @override + String get taskTag_cornerIsGoldSideIsSilverCenterIsGrass => + 'Colțul e aur, marginea e argint, centrul e iarbă'; + + @override + String get taskTag_counter => 'Contra'; + + @override + String get taskTag_counterAttack => 'Contraatac'; + + @override + String get taskTag_cranesNest => 'Cuibul cocorului'; + + @override + String get taskTag_crawl => 'Târâre'; + + @override + String get taskTag_createShortageOfLiberties => 'Creează lipsă de libertăți'; + + @override + String get taskTag_crossedFive => 'Cinci încrucișate'; + + @override + String get taskTag_cut => 'Taie'; + + @override + String get taskTag_cut2 => 'Taie'; + + @override + String get taskTag_cutAcross => 'Taie de-a curmezișul'; + + @override + String get taskTag_defendFromInvasion => 'Apără de invazie'; + + @override + String get taskTag_defendPoints => 'Apără punctele'; + + @override + String get taskTag_defendWeakPoint => 'Apără punctul slab'; + + @override + String get taskTag_descent => 'Coborâre'; + + @override + String get taskTag_diagonal => 'Diagonală'; + + @override + String get taskTag_directionOfCapture => 'Direcția capturii'; + + @override + String get taskTag_directionOfEscape => 'Direcția evadării'; + + @override + String get taskTag_directionOfPlay => 'Direcția jocului'; + + @override + String get taskTag_doNotUnderestimateOpponent => 'Nu-ți subestima adversarul'; + + @override + String get taskTag_doubleAtari => 'Dublu atari'; + + @override + String get taskTag_doubleCapture => 'Captură dublă'; + + @override + String get taskTag_doubleKo => 'Ko dublu'; + + @override + String get taskTag_doubleSenteEndgame => 'Final dublu sente'; + + @override + String get taskTag_doubleSnapback => 'Snapback dublu'; + + @override + String get taskTag_endgame => 'Final: general'; + + @override + String get taskTag_endgameFundamentals => 'Bazele finalului'; + + @override + String get taskTag_endgameIn5x5 => 'Final pe 5x5'; + + @override + String get taskTag_endgameOn4x4 => 'Final pe 4x4'; + + @override + String get taskTag_endgameTesuji => 'Tesuji de final'; + + @override + String get taskTag_engulfingAtari => 'Atari de învăluire'; + + @override + String get taskTag_escape => 'Evadare'; + + @override + String get taskTag_escapeInOneMove => 'Evadare dintr-o mutare'; + + @override + String get taskTag_exploitShapeWeakness => 'Exploatează slăbiciunea formei'; + + @override + String get taskTag_eyeVsNoEye => 'Ochi vs fără ochi'; + + @override + String get taskTag_fillNeutralPoints => 'Umple punctele neutre'; + + @override + String get taskTag_findTheRoot => 'Găsește rădăcina'; + + @override + String get taskTag_firstLineBrilliantMove => + 'Mutare strălucită pe prima linie'; + + @override + String get taskTag_flowerSix => 'Șase în formă de floare'; + + @override + String get taskTag_goldenChickenStandingOnOneLeg => + 'Cocoșul de aur stând pe un picior'; + + @override + String get taskTag_groupLiberties => 'Libertățile grupului'; + + @override + String get taskTag_groupsBase => 'Baza grupului'; + + @override + String get taskTag_hane => 'Hane'; + + @override + String get taskTag_increaseEyeSpace => 'Mărește spațiul ochiului'; + + @override + String get taskTag_increaseLiberties => 'Mărește libertățile'; + + @override + String get taskTag_indirectAttack => 'Atac indirect'; + + @override + String get taskTag_influenceKeyPoints => 'Puncte-cheie de influență'; + + @override + String get taskTag_insideKill => 'Ucidere din interior'; + + @override + String get taskTag_insideMoves => 'Mutări interioare'; + + @override + String get taskTag_interestingTasks => 'Sarcini interesante'; + + @override + String get taskTag_internalLibertyShortage => 'Lipsă internă de libertăți'; + + @override + String get taskTag_invadingTechnique => 'Tehnică de invazie'; + + @override + String get taskTag_invasion => 'Invazie'; + + @override + String get taskTag_jGroupAndSimilar => 'Grupul J și forme similare'; + + @override + String get taskTag_josekiFundamentals => 'Bazele joseki'; + + @override + String get taskTag_jump => 'Salt'; + + @override + String get taskTag_keepSente => 'Păstrează sente'; + + @override + String get taskTag_killAfterCapture => 'Ucide după captură'; + + @override + String get taskTag_killByEyePointPlacement => + 'Ucide prin plasarea pe punctul ochiului'; + + @override + String get taskTag_knightsMove => 'Mutarea calului'; + + @override + String get taskTag_ko => 'Ko'; + + @override + String get taskTag_kosumiWedge => 'Pană kosumi'; + + @override + String get taskTag_largeKnightsMove => 'Mutarea mare a calului'; + + @override + String get taskTag_largeMoyoFight => 'Luptă în moyo mare'; + + @override + String get taskTag_lifeAndDeath => 'Viață și moarte: general'; + + @override + String get taskTag_lifeAndDeathOn4x4 => 'Viață și moarte pe 4x4'; + + @override + String get taskTag_lookForLeverage => 'Caută avantajul (leverage)'; + + @override + String get taskTag_looseLadder => 'Scară lejeră'; + + @override + String get taskTag_lovesickCut => 'Tăietură a îndrăgostitului'; + + @override + String get taskTag_makeEye => 'Creează un ochi'; + + @override + String get taskTag_makeEyeInOneStep => 'Creează un ochi dintr-o mutare'; + + @override + String get taskTag_makeEyeInSente => 'Creează un ochi în sente'; + + @override + String get taskTag_makeKo => 'Creează un ko'; + + @override + String get taskTag_makeShape => 'Creează formă'; + + @override + String get taskTag_middlegame => 'Joc de mijloc'; + + @override + String get taskTag_monkeyClimbingMountain => 'Maimuța urcând muntele'; + + @override + String get taskTag_mouseStealingOil => 'Șoarecele furând ulei'; + + @override + String get taskTag_moveOut => 'Ieși afară'; + + @override + String get taskTag_moveTowardsEmptySpace => 'Mută-te spre spațiul gol'; + + @override + String get taskTag_multipleBrilliantMoves => 'Mai multe mutări strălucite'; + + @override + String get taskTag_net => 'Plasă'; + + @override + String get taskTag_netCapture => 'Captură prin plasă'; + + @override + String get taskTag_observeSubtleDifference => 'Observă diferența subtilă'; + + @override + String get taskTag_occupyEncloseAndApproachCorner => + 'Ocupă, închide și abordează colțurile'; + + @override + String get taskTag_oneStoneTwoPurposes => 'O piatră, două scopuri'; + + @override + String get taskTag_opening => 'Deschidere'; + + @override + String get taskTag_openingChoice => 'Alegerea deschiderii'; + + @override + String get taskTag_openingFundamentals => 'Bazele deschiderii'; + + @override + String get taskTag_orderOfEndgameMoves => 'Ordinea mutărilor în final'; + + @override + String get taskTag_orderOfMoves => 'Ordinea mutărilor'; + + @override + String get taskTag_orderOfMovesInKo => 'Ordinea mutărilor într-un ko'; + + @override + String get taskTag_orioleCapturesButterfly => + 'Privighetoarea capturează fluturele'; + + @override + String get taskTag_pincer => 'Clește (pincer)'; + + @override + String get taskTag_placement => 'Plasare'; + + @override + String get taskTag_plunderingTechnique => 'Tehnică de jefuire'; + + @override + String get taskTag_preventBambooJoint => 'Previne îmbinarea de bambus'; + + @override + String get taskTag_preventBridgingUnder => 'Previne podul dedesubt'; + + @override + String get taskTag_preventOpponentFromApproaching => + 'Împiedică adversarul să se apropie'; + + @override + String get taskTag_probe => 'Sondaj (probe)'; + + @override + String get taskTag_profitInSente => 'Profit în sente'; + + @override + String get taskTag_profitUsingLifeAndDeath => + 'Profit folosind viață și moarte'; + + @override + String get taskTag_push => 'Împinge'; + + @override + String get taskTag_pyramidFour => 'Piramidă de patru'; + + @override + String get taskTag_realEyeAndFalseEye => 'Ochi real vs ochi fals'; + + @override + String get taskTag_rectangularSix => 'Șase dreptunghiulare'; + + @override + String get taskTag_reduceEyeSpace => 'Reduce spațiul ochiului'; + + @override + String get taskTag_reduceLiberties => 'Reduce libertățile'; + + @override + String get taskTag_reduction => 'Reducere'; + + @override + String get taskTag_runWeakGroup => 'Fă grupul slab să fugă'; + + @override + String get taskTag_sabakiAndUtilizingInfluence => + 'Sabaki și utilizarea influenței'; + + @override + String get taskTag_sacrifice => 'Sacrificiu'; + + @override + String get taskTag_sacrificeAndSqueeze => 'Sacrificiu și strângere'; + + @override + String get taskTag_sealIn => 'Încercuiește (sigilează înăuntru)'; + + @override + String get taskTag_secondLine => 'A doua linie'; + + @override + String get taskTag_seizeTheOpportunity => 'Prinde oportunitatea'; + + @override + String get taskTag_seki => 'Seki'; + + @override + String get taskTag_senteAndGote => 'Sente și gote'; + + @override + String get taskTag_settleShape => 'Stabilizează forma'; + + @override + String get taskTag_settleShapeInSente => 'Stabilizează forma în sente'; + + @override + String get taskTag_shape => 'Formă'; + + @override + String get taskTag_shapesVitalPoint => 'Punctul vital al formei'; + + @override + String get taskTag_side => 'Margine'; + + @override + String get taskTag_smallBoardEndgame => 'Final pe tablă mică'; + + @override + String get taskTag_snapback => 'Snapback'; + + @override + String get taskTag_solidConnection => 'Conectare solidă'; + + @override + String get taskTag_solidExtension => 'Extensie solidă'; + + @override + String get taskTag_splitInOneMove => 'Desparte dintr-o mutare'; + + @override + String get taskTag_splittingMove => 'Mutare de separare'; + + @override + String get taskTag_squareFour => 'Pătrat de patru'; + + @override + String get taskTag_squeeze => 'Strângere'; + + @override + String get taskTag_standardCapturingRaces => 'Cursă standard de capturare'; + + @override + String get taskTag_standardCornerAndSideEndgame => + 'Final standard de colț și margine'; + + @override + String get taskTag_straightFour => 'Patru în linie'; + + @override + String get taskTag_straightThree => 'Trei în linie'; + + @override + String get taskTag_surroundTerritory => 'Încercuiește teritoriul'; + + @override + String get taskTag_symmetricShape => 'Formă simetrică'; + + @override + String get taskTag_techniqueForReinforcingGroups => + 'Tehnică de consolidare a grupurilor'; + + @override + String get taskTag_techniqueForSecuringTerritory => + 'Tehnică pentru asigurarea teritoriului'; + + @override + String get taskTag_textbookTasks => 'Sarcini din manual'; + + @override + String get taskTag_thirdAndFourthLine => 'Linia a treia și a patra'; + + @override + String get taskTag_threeEyesTwoActions => 'Trei ochi, două acțiuni'; + + @override + String get taskTag_threeSpaceExtensionFromTwoStones => + 'Extensie de trei spații de la două pietre'; + + @override + String get taskTag_throwIn => 'Aruncare înăuntru (throw-in)'; + + @override + String get taskTag_tigersMouth => 'Gura tigrului'; + + @override + String get taskTag_tombstoneSqueeze => 'Strângerea pietrei funerare'; + + @override + String get taskTag_tripodGroupWithExtraLegAndSimilar => + 'Grup tripod cu picior suplimentar și forme similare'; + + @override + String get taskTag_twoHaneGainOneLiberty => 'Dublu hane câștigă o libertate'; + + @override + String get taskTag_twoHeadedDragon => 'Dragon cu două capete'; + + @override + String get taskTag_twoSpaceExtension => 'Extensie de două spații'; + + @override + String get taskTag_typesOfKo => 'Tipuri de ko'; + + @override + String get taskTag_underTheStones => 'Sub pietre'; + + @override + String get taskTag_underneathAttachment => 'Atașare dedesubt'; + + @override + String get taskTag_urgentPointOfAFight => 'Punct urgent al luptei'; + + @override + String get taskTag_urgentPoints => 'Puncte urgente'; + + @override + String get taskTag_useConnectAndDie => 'Folosește conectează și mori'; + + @override + String get taskTag_useCornerSpecialProperties => + 'Folosește proprietățile speciale ale colțului'; + + @override + String get taskTag_useDescentToFirstLine => + 'Folosește coborârea pe prima linie'; + + @override + String get taskTag_useInfluence => 'Folosește influența'; + + @override + String get taskTag_useOpponentsLifeAndDeath => + 'Folosește viața și moartea adversarului'; + + @override + String get taskTag_useShortageOfLiberties => 'Folosește lipsa de libertăți'; + + @override + String get taskTag_useSnapback => 'Folosește snapback'; + + @override + String get taskTag_useSurroundingStones => 'Folosește pietrele din jur'; + + @override + String get taskTag_vitalAndUselessStones => 'Pietre vitale și inutile'; + + @override + String get taskTag_vitalPointForBothSides => + 'Punct vital pentru ambele părți'; + + @override + String get taskTag_vitalPointForCapturingRace => + 'Punct vital pentru cursa de capturare'; + + @override + String get taskTag_vitalPointForIncreasingLiberties => + 'Punct vital pentru creșterea libertăților'; + + @override + String get taskTag_vitalPointForKill => 'Punct vital pentru ucidere'; + + @override + String get taskTag_vitalPointForLife => 'Punct vital pentru viață'; + + @override + String get taskTag_vitalPointForReducingLiberties => + 'Punct vital pentru reducerea libertăților'; + + @override + String get taskTag_wedge => 'Pană'; + + @override + String get taskTag_wedgingCapture => 'Captură prin pană'; + + @override + String get taskTimeout => 'Timp expirat'; + + @override + String get taskTypeAppreciation => 'Apreciere'; + + @override + String get taskTypeCapture => 'Capturare de pietre'; + + @override + String get taskTypeCaptureRace => 'Cursă de capturare'; + + @override + String get taskTypeEndgame => 'Final'; + + @override + String get taskTypeJoseki => 'Joseki'; + + @override + String get taskTypeLifeAndDeath => 'Viață și moarte'; + + @override + String get taskTypeMiddlegame => 'Joc de mijloc'; + + @override + String get taskTypeOpening => 'Deschidere'; + + @override + String get taskTypeTesuji => 'Tesuji'; + + @override + String get taskTypeTheory => 'Teorie'; + + @override + String get taskWrong => 'Greșit'; + + @override + String get tasksSolved => 'Sarcini rezolvate'; + + @override + String get test => 'Test'; + + @override + String get theme => 'Temă'; + + @override + String get thick => 'Gros'; + + @override + String get timeFrenzy => 'Go contra cronometru'; + + @override + String get timeFrenzyMistakes => + 'Urmărește greșelile din Go contra cronometru'; + + @override + String get timeFrenzyMistakesDesc => + 'Activează pentru a salva greșelile făcute în Go contra cronometru'; + + @override + String get randomizeTaskOrientation => 'Orientare aleatoare a tzumego'; + + @override + String get randomizeTaskOrientationDesc => + 'Rotește și reflectă aleator problemele de tsumego de-a lungul axelor orizontale, verticale și diagonale pentru a preveni memorarea și a îmbunătăți recunoașterea modelelor.'; + + @override + String get timePerTask => 'Timp per sarcină'; + + @override + String get today => 'Astăzi'; + + @override + String get tooltipAnalyzeWithAISensei => 'Analizează cu AI Sensei'; + + @override + String get tooltipDownloadGame => 'Descarcă jocul'; + + @override + String get topic => 'Subiect'; + + @override + String get topicExam => 'Examen pe subiect'; + + @override + String get topics => 'Subiecte'; + + @override + String get train => 'Antrenează-te'; + + @override + String get trainingAvgTimePerTask => 'Timp mediu per sarcină'; + + @override + String get trainingFailed => 'Eșuat'; + + @override + String get trainingMistakes => 'Greșeli'; + + @override + String get trainingPassed => 'Promovat'; + + @override + String get trainingTotalTime => 'Timp total'; + + @override + String get tryCustomMoves => 'Încearcă mutări personalizate'; + + @override + String get tygemDesc => + 'Cel mai popular server din Coreea și unul dintre cele mai populare din lume.'; + + @override + String get tygemName => 'Tygem Baduk'; + + @override + String get type => 'Tip'; + + @override + String get ui => 'Interfață utilizator'; + + @override + String get userInfo => 'Informații utilizator'; + + @override + String get username => 'Nume de utilizator'; + + @override + String get voice => 'Voce'; + + @override + String get week => 'Săptămână'; + + @override + String get white => 'Alb'; + + @override + String get yes => 'Da'; +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart new file mode 100644 index 00000000..77b1d9e6 --- /dev/null +++ b/lib/l10n/app_localizations_ru.dart @@ -0,0 +1,1420 @@ +// 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 acceptDeadStones => 'Принять мёртвые камни'; + + @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 => 'Интерфейс'; + + @override + String get autoCounting => 'Автоподсчёт'; + + @override + String get autoMatch => 'Автоподбор'; + + @override + String get 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 clearBoard => 'Очистить'; + + @override + String get collectStats => 'Добавлять в статистику'; + + @override + String get collections => 'Коллекции'; + + @override + String get confirm => 'Подтвердить'; + + @override + String get confirmBoardSize => 'Минимальный размер доски'; + + @override + String get confirmBoardSizeDesc => + 'Подтверждение ходов требуется для досок этого размера и больше'; + + @override + String get confirmMoves => 'Подтверждение ходов'; + + @override + String get confirmMovesDesc => + 'Двойное нажатие на один и тот же пункт для подтверждения хода, чтобы избежать случайных нажатий'; + + @override + String get continue_ => 'Продолжить'; + + @override + String get copySGF => 'Копировать SGF'; + + @override + String get copyTaskLink => 'Копировать ссылку на задачу'; + + @override + String get customExam => 'Пользовательский экзамен'; + + @override + String get dark => 'Тёмная'; + + @override + String get dontShowAgain => 'Больше не показывать'; + + @override + String get download => 'Скачать'; + + @override + String get edgeLine => 'Линия края'; + + @override + String get empty => 'Пусто'; + + @override + String get endgameExam => 'Экзамены по йосе'; + + @override + String get enterTaskLink => 'Введите ссылку на задачу'; + + @override + String get errCannotBeEmpty => 'Обязательное поле'; + + @override + String get errFailedToDownloadGame => 'Не удалось скачать партию'; + + @override + String get errFailedToLoadGameList => + 'Не удалось загрузить список партий. Пожалуйста, попробуйте снова.'; + + @override + String get errFailedToUploadGameToAISensei => + 'Не удалось загрузить партию в AI Sensei'; + + @override + String get errIncorrectUsernameOrPassword => + 'Неверное имя пользователя или пароль'; + + @override + String errMustBeAtLeast(num n) { + return 'Должно быть не менее $n'; + } + + @override + String errMustBeAtMost(num n) { + return 'Должно быть не более $n'; + } + + @override + String get errMustBeInteger => 'Должно быть целым числом'; + + @override + String get exit => 'Выход'; + + @override + String get exitTryMode => 'Выйти из режима пробных ходов'; + + @override + String get find => 'Найти'; + + @override + String get findTask => 'Найти задачу'; + + @override + String get findTaskByLink => 'По ссылке'; + + @override + String get findTaskByPattern => 'По позиции'; + + @override + String get findTaskResults => 'Результаты поиска'; + + @override + String get findTaskSearching => 'Поиск...'; + + @override + String get forceCounting => 'Принудительный подсчёт'; + + @override + String get foxwqDesc => 'Самый популярный сервер в Китае и в мире.'; + + @override + String get foxwqName => 'Фокс Вэйци'; + + @override + String get gameInfo => 'Информация о партии'; + + @override + String get gameRecord => 'Запись партии'; + + @override + String get gradingExam => 'Контрольный экзамен'; + + @override + String get handicap => 'Фора'; + + @override + String get help => 'Помощь'; + + @override + String get helpDialogCollections => + 'Коллекции — это классические, тщательно отобранные наборы высококачественных задач, которые вместе представляют особую ценность как учебный ресурс.\n\nОсновная цель — решить коллекцию с высоким процентом успеха. Вторичная цель — решить её как можно быстрее.\n\nЧтобы начать или продолжить решение коллекции, смахните влево по плитке коллекции в портретном режиме или нажмите кнопки «Начать»/«Продолжить» в ландшафтном режиме.'; + + @override + String get helpDialogEndgameExam => + '- Экзамены по йосе — это наборы из 10 задач по йосе, и у вас есть 45 секунд на каждую задачу.\n\n- Вы сдаёте экзамен, если решаете 8 или более задач правильно (80% успеха).\n\n- Сдача экзамена для данного ранга открывает экзамен для следующего ранга.'; + + @override + String get helpDialogGradingExam => + '- Контрольные экзамены — это наборы из 10 задач, и у вас есть 45 секунд на каждую задачу.\n\n- Вы сдаёте экзамен, если решаете 8 или более задач правильно (80% успеха).\n\n- Сдача экзамена для данного ранга открывает экзамен для следующего ранга.'; + + @override + String get helpDialogRankedMode => + '- Решайте задачи без ограничения по времени.\n\n- Сложность задач увеличивается в зависимости от того, насколько быстро вы их решаете.\n\n- Сосредоточьтесь на правильном решении и достигните максимально возможного ранга.'; + + @override + String get helpDialogTimeFrenzy => + '- У вас есть 3 минуты, чтобы решить как можно больше задач.\n\n- Задачи становятся всё сложнее по мере их решения.\n\n- Если вы допустите 3 ошибки, вы проиграете.'; + + @override + String get hideTask => 'Удалить из ошибок'; + + @override + String get home => 'Главная'; + + @override + String get komi => 'Коми'; + + @override + String get language => 'Язык'; + + @override + String get leave => 'Покинуть'; + + @override + String get light => 'Светлая'; + + @override + String get login => 'Войти'; + + @override + String get logout => 'Выйти'; + + @override + String get long => 'Длинная'; + + @override + String mMinutes(int m) { + return '$m мин'; + } + + @override + String get maxNumberOfMistakes => 'Максимальное количество ошибок'; + + @override + String get maxRank => 'Макс. ранг'; + + @override + String get medium => 'Средняя'; + + @override + String get minRank => 'Мин. ранг'; + + @override + String get minutes => 'Минуты'; + + @override + String get month => 'Месяц'; + + @override + String get msgCannotUseAIRefereeYet => 'ИИ-судья ещё нельзя использовать'; + + @override + String get msgCannotUseForcedCountingYet => + 'Принудительный подсчёт ещё нельзя использовать'; + + @override + String get msgConfirmDeleteCollectionProgress => + 'Вы уверены, что хотите начать коллекцию заново? Текущий прогресс будет удалён.'; + + @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 msgSgfCopied => 'SGF скопирован в буфер обмена'; + + @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 pleaseMarkDeadStones => 'Пожалуйста, отметьте мёртвые камни.'; + + @override + String get promotionRequirements => 'Требования для повышения'; + + @override + String pxsByoyomi(int p, int s) { + return '$p×$sс'; + } + + @override + String get rank => 'Ранг'; + + @override + String get rankedMode => 'Рейтинговый режим'; + + @override + String get recentRecord => 'Недавние результаты'; + + @override + String get register => 'Зарегистрироваться'; + + @override + String get rejectDeadStones => 'Отклонить мёртвые камни'; + + @override + String get 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 resultAccept => 'Принять'; + + @override + String get resultReject => 'Отказаться'; + + @override + String get rules => 'Правила'; + + @override + String get rulesChinese => 'Китайские'; + + @override + String get rulesJapanese => 'Японские'; + + @override + String get rulesKorean => 'Корейские'; + + @override + String sSeconds(int s) { + return '$s с'; + } + + @override + String get save => 'Сохранить'; + + @override + String get saveSGF => 'Сохранить SGF'; + + @override + String get seconds => 'Секунды'; + + @override + String get settings => 'Настройки'; + + @override + String get short => 'Короткая'; + + @override + String get showCoordinates => 'Показывать координаты'; + + @override + String get showMoveErrorsAsCrosses => 'Отображать неправильные ходы крестами'; + + @override + String get showMoveErrorsAsCrossesDesc => + 'Отображать неправильные ходы красными крестами вместо красных точек'; + + @override + String get 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 taskNotFound => 'Задача не найдена'; + + @override + String get taskRedo => 'Переделать'; + + @override + String get taskSource => 'Происхождение задач'; + + @override + String get taskSourceFromMyMistakes => 'Из моих ошибок'; + + @override + String get taskSourceFromTaskTopic => 'Из темы задач'; + + @override + String get taskSourceFromTaskTypes => 'Из типов задач'; + + @override + String get taskTag_afterJoseki => 'После дзёсэки'; + + @override + String get taskTag_aiOpening => 'Фусэки от ИИ'; + + @override + String get taskTag_aiVariations => 'Варианты от ИИ'; + + @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х5 йосе'; + + @override + String get taskTag_endgameOn4x4 => '4х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 => 'Блестящий ход на 1-й линии'; + + @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х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 => '2-я линия'; + + @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 => '3-я и 4-я линия'; + + @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 => 'Время вышло'; + + @override + String get taskTypeAppreciation => 'Оценка позиции'; + + @override + String get taskTypeCapture => 'Захват камней'; + + @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 => 'Толстая'; + + @override + String get timeFrenzy => 'Временной раж'; + + @override + String get timeFrenzyMistakes => 'Отслеживать ошибки во Временном раже'; + + @override + String get timeFrenzyMistakesDesc => + 'Включить для сохранения ошибок, сделанных во Временном раже'; + + @override + String get randomizeTaskOrientation => 'Случайная ориентация задач'; + + @override + String get randomizeTaskOrientationDesc => + 'Случайно поворачивает и отражает задачи вдоль горизонтальной, вертикальной и диагональной осей, чтобы предотвратить запоминание и улучшить распознавание паттернов.'; + + @override + String get timePerTask => 'Время на задачу'; + + @override + String get today => 'Сегодня'; + + @override + String get tooltipAnalyzeWithAISensei => 'Анализировать с AI Sensei'; + + @override + String get tooltipDownloadGame => 'Скачать партию'; + + @override + String get topic => 'Тема'; + + @override + String get topicExam => 'Тематический экзамен'; + + @override + String get topics => 'Темы'; + + @override + String get train => 'Тренироваться'; + + @override + String get trainingAvgTimePerTask => 'Ср. время на задачу'; + + @override + String get trainingFailed => 'Не сдано'; + + @override + String get trainingMistakes => 'Ошибки'; + + @override + String get trainingPassed => 'Сдано'; + + @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..4026f2e0 --- /dev/null +++ b/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,1379 @@ +// 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 acceptDeadStones => '确认死子'; + + @override + String get accuracy => '答题准确率'; + + @override + String get aiReferee => 'AI 裁判'; + + @override + String get aiSensei => 'AI Sensei'; + + @override + String get alwaysBlackToPlay => '总是执黑先行'; + + @override + String get alwaysBlackToPlayDesc => '永远执黑先行,防止混淆'; + + @override + String get appearance => '外观'; + + @override + String get autoCounting => '自动点目'; + + @override + String get autoMatch => '自动匹配'; + + @override + String get 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 clearBoard => '清空'; + + @override + String get collectStats => '计入统计'; + + @override + String get collections => '棋书'; + + @override + String get confirm => '确认'; + + @override + String get confirmBoardSize => '确认棋盘大小'; + + @override + String get confirmBoardSizeDesc => '此尺寸及更大的棋盘需要确认落子'; + + @override + String get confirmMoves => '确定落子'; + + @override + String get confirmMovesDesc => '在较大的棋盘上双击以确认落子,避免误操作'; + + @override + String get continue_ => '继续'; + + @override + String get copySGF => '复制SGF'; + + @override + String get copyTaskLink => '复制题目链接'; + + @override + String get customExam => '自定义测试'; + + @override + String get dark => '深色'; + + @override + String get dontShowAgain => '不再显示'; + + @override + String get download => '下载'; + + @override + String get edgeLine => '棋盘边缘'; + + @override + String get empty => '空'; + + @override + String get endgameExam => '官子测试'; + + @override + String get enterTaskLink => '输入题目链接'; + + @override + String get errCannotBeEmpty => '不能为空'; + + @override + String get errFailedToDownloadGame => '下载棋局失败'; + + @override + String get errFailedToLoadGameList => '加载棋局列表失败,请重试。'; + + @override + String get errFailedToUploadGameToAISensei => '上传棋局到 AI Sensei 失败'; + + @override + String get errIncorrectUsernameOrPassword => '用户名或密码错误'; + + @override + String errMustBeAtLeast(num n) { + return '必须至少$n'; + } + + @override + String errMustBeAtMost(num n) { + return '必须最多$n'; + } + + @override + String get errMustBeInteger => '必须为整数'; + + @override + String get exit => '退出'; + + @override + String get exitTryMode => '退出试下模式'; + + @override + String get find => '查找'; + + @override + String get findTask => '搜题'; + + @override + String get findTaskByLink => '链接搜题'; + + @override + String get findTaskByPattern => '棋形搜题'; + + @override + String get findTaskResults => '搜题结果'; + + @override + String get findTaskSearching => '搜题中...'; + + @override + String get forceCounting => '强制数子'; + + @override + String get foxwqDesc => '野狐围棋是精心打造的专业围棋对弈、社交软件。'; + + @override + String get foxwqName => 'Fox Weiqi'; + + @override + String get gameInfo => '信息'; + + @override + String get gameRecord => '棋谱'; + + @override + String get gradingExam => '棋力测试'; + + @override + String get handicap => '让子数'; + + @override + String get help => '帮助'; + + @override + String get helpDialogCollections => + '棋书是经典的、精选的高质量题目合集,作为训练资源具有特殊价值。\n\n主要目标是以高成功率解完一本棋书。次要目标是尽可能快地完成。\n\n要开始或继续解答棋书,请在竖屏模式下向左滑动棋书图块,或在横屏模式下点击「开始」/「继续」按钮。'; + + @override + String get helpDialogEndgameExam => + '- 官子测试包含10道官子题,每题限时45秒。\n\n- 答对8题及以上(80%正确率)即为通过。\n\n- 通过当前级别测试将解锁下一级别。'; + + @override + String get helpDialogGradingExam => + '- 棋力测试包含10道题,每题限时45秒。\n\n- 答对8题及以上(80%正确率)即为通过。\n\n- 通过当前级别测试将解锁下一级别。'; + + @override + String get helpDialogRankedMode => + '- 无时间限制答题。\n\n- 题目难度会根据您的解题速度动态调整。\n\n- 专注于正确答题,冲击最高等级。'; + + @override + String get helpDialogTimeFrenzy => + '- 3分钟内解答尽可能多的题。\n\n- 题目难度会随着您的进度逐渐增加。\n\n- 累计答错3题即结束。'; + + @override + String get hideTask => '移出错题集'; + + @override + String get home => '主界面'; + + @override + String get komi => '贴目'; + + @override + String get language => '语言'; + + @override + String get leave => '退出房间'; + + @override + String get light => '浅色'; + + @override + String get login => '登录'; + + @override + String get logout => '退出'; + + @override + String get long => '长'; + + @override + String mMinutes(int m) { + return '$m分钟'; + } + + @override + String get maxNumberOfMistakes => '最大错误数'; + + @override + String get maxRank => '最高段位'; + + @override + String get medium => '中'; + + @override + String get minRank => '最低段位'; + + @override + String get minutes => '分'; + + @override + String get month => '月'; + + @override + String get msgCannotUseAIRefereeYet => '您还不能使用 AI 裁判'; + + @override + String get msgCannotUseForcedCountingYet => '手数不足,不可强制数子或强制点目'; + + @override + String get msgConfirmDeleteCollectionProgress => '确定要删除之前的棋书进度吗?'; + + @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 msgSgfCopied => 'SGF已复制到剪贴板'; + + @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题', + 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题', + 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 pleaseMarkDeadStones => '请标记死子。'; + + @override + String get promotionRequirements => '升级要求'; + + @override + String pxsByoyomi(int p, int s) { + return '$p×${s}s'; + } + + @override + String get rank => '段位'; + + @override + String get rankedMode => '等级模式'; + + @override + String get recentRecord => '当前战绩'; + + @override + String get register => '创建帐号'; + + @override + String get rejectDeadStones => '取消死子'; + + @override + String get 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 resultAccept => '接受'; + + @override + String get resultReject => '拒绝'; + + @override + String get rules => '规则'; + + @override + String get rulesChinese => '中国规则'; + + @override + String get rulesJapanese => '日本规则'; + + @override + String get rulesKorean => '韩国规则'; + + @override + String sSeconds(int s) { + return '$s秒'; + } + + @override + String get save => '保存'; + + @override + String get saveSGF => '保存SGF'; + + @override + String get seconds => '秒'; + + @override + String get settings => '设置'; + + @override + String get short => '短'; + + @override + String get showCoordinates => '显示坐标'; + + @override + String get showMoveErrorsAsCrosses => '显示错误着法为叉号'; + + @override + String get showMoveErrorsAsCrossesDesc => '将错误着法显示为红色叉号而非红色圆点'; + + @override + String get 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 taskNotFound => '未找到该题'; + + @override + String get taskRedo => '重做题'; + + @override + String get taskSource => '题库来源'; + + @override + String get taskSourceFromMyMistakes => '来自我的错题'; + + @override + String get taskSourceFromTaskTopic => '来自专题题库'; + + @override + String get taskSourceFromTaskTypes => '来自题型分类'; + + @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 => '答题超时'; + + @override + String get taskTypeAppreciation => '欣赏题'; + + @override + String get taskTypeCapture => '吃子题'; + + @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 => '粗线'; + + @override + String get timeFrenzy => '限时挑战'; + + @override + String get timeFrenzyMistakes => '记录限时挑战错误'; + + @override + String get timeFrenzyMistakesDesc => '启用以保存限时挑战中的错误'; + + @override + String get randomizeTaskOrientation => '随机化题目方向'; + + @override + String get randomizeTaskOrientationDesc => + '随机旋转和翻转题目,沿水平、垂直和对角轴线,防止记忆化并增强模式识别能力。'; + + @override + String get timePerTask => '单题用时'; + + @override + String get today => '今日'; + + @override + String get tooltipAnalyzeWithAISensei => '使用 AI Sensei 分析'; + + @override + String get tooltipDownloadGame => '下载棋局'; + + @override + String get topic => '主题'; + + @override + String get topicExam => '主题测试'; + + @override + String get topics => '主题'; + + @override + String get train => '练习'; + + @override + String get trainingAvgTimePerTask => '平均单题用时'; + + @override + String get trainingFailed => '未通过'; + + @override + String get trainingMistakes => '错误'; + + @override + String get trainingPassed => '通过'; + + @override + String get trainingTotalTime => '总用时'; + + @override + String get tryCustomMoves => '试下'; + + @override + String get tygemDesc => 'Tygem 是韩国最受欢迎的围棋对弈平台,以其激烈的在线对局氛围而闻名世界。'; + + @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_ro.arb b/lib/l10n/app_ro.arb new file mode 100644 index 00000000..19659aa7 --- /dev/null +++ b/lib/l10n/app_ro.arb @@ -0,0 +1,518 @@ +{ + "about": "Despre", + "acceptDeadStones": "Acceptă pietrele moarte", + "accuracy": "Precizie", + "aiReferee": "Arbitru AI", + "aiSensei": "AI Sensei", + "alwaysBlackToPlay": "Întotdeauna negru la mutare", + "alwaysBlackToPlayDesc": "Setează toate sarcinile ca fiind negru la mutare pentru a evita confuzia", + "appearance": "Aspect", + "autoCounting": "Numărare automată", + "autoMatch": "Potrivire automată", + "behaviour": "Comportament", + "bestResult": "Cel mai bun rezultat", + "black": "Negru", + "board": "Tabelă", + "boardSize": "Dimensiunea tablei", + "boardTheme": "Temă a tablei", + "byRank": "După rang", + "cancel": "Anulează", + "captures": "Capturi", + "clearBoard": "Curăță tabla", + "collectStats": "Colectează statistici", + "collections": "Colecții", + "confirm": "Confirmă", + "confirmBoardSize": "Confirmă dimensiunea tablei", + "confirmBoardSizeDesc": "Tablele de această dimensiune sau mai mari necesită confirmarea mutărilor", + "confirmMoves": "Confirmă mutările", + "confirmMovesDesc": "Atinge de două ori pentru a confirma mutările pe table mari pentru a evita greșelile", + "continue_": "Continuă", + "copySGF": "Copiază SGF", + "copyTaskLink": "Copiază linkul sarcinii", + "customExam": "Examen personalizat", + "dark": "Întunecat", + "dontShowAgain": "Nu mai arăta din nou", + "download": "Descarcă", + "edgeLine": "Linie de margine", + "empty": "Gol", + "endgameExam": "Examen de sfirsitul jocului", + "enterTaskLink": "Introdu linkul sarcinii", + "errCannotBeEmpty": "Nu poate fi gol", + "errFailedToDownloadGame": "Descărcarea jocului a eșuat", + "errFailedToLoadGameList": "Încărcarea listei de jocuri a eșuat. Te rugăm să încerci din nou.", + "errFailedToUploadGameToAISensei": "Încărcarea jocului în AI Sensei a eșuat", + "errIncorrectUsernameOrPassword": "Nume de utilizator sau parolă incorecte", + "errMustBeAtLeast": "Trebuie să fie cel puțin {n}", + "@errMustBeAtLeast": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeAtMost": "Trebuie să fie cel mult {n}", + "@errMustBeAtMost": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeInteger": "Trebuie să fie un număr întreg", + "exit": "Ieșire", + "exitTryMode": "Ieși din modul de încercare", + "find": "Caută", + "findTask": "Caută sarcina", + "findTaskByLink": "După link", + "findTaskByPattern": "După tipar", + "findTaskResults": "Rezultatele căutării", + "findTaskSearching": "Se caută...", + "forceCounting": "Forțează numărarea", + "foxwqDesc": "Cel mai popular server din China și din lume.", + "foxwqName": "Fox Weiqi", + "gameInfo": "Informații despre joc", + "gameRecord": "Înregistrare joc", + "gradingExam": "Examen de clasificare", + "handicap": "Handicap", + "help": "Ajutor", + "helpDialogCollections": "Colecțiile sunt seturi clasice, selectate cu grijă, de sarcini de înaltă calitate care au o valoare specială ca resursă de antrenament.\n\nObiectivul principal este să rezolvi o colecție cu o rată mare de succes. Un obiectiv secundar este să o rezolvi cât mai repede posibil.\n\nPentru a începe sau a continua rezolvarea unei colecții, glisează spre stânga pe elementul colecției în modul portret sau apasă butoanele Start/Continuă în modul peisaj.", + "helpDialogEndgameExam": "- Examenele de final sunt seturi de 10 sarcini de final și ai 45 de secunde pentru fiecare.\n\n- Promovezi examenul dacă rezolvi corect 8 sau mai multe (rata de succes 80%).\n\n- Promovarea examenului pentru un anumit rang deblochează examenul pentru următorul rang.", + "helpDialogGradingExam": "- Examenele de clasificare sunt seturi de 10 sarcini și ai 45 de secunde pentru fiecare.\n\n- Promovezi examenul dacă rezolvi corect 8 sau mai multe (rata de succes 80%).\n\n- Promovarea examenului pentru un anumit rang deblochează examenul pentru următorul rang.", + "helpDialogRankedMode": "- Rezolvă sarcini fără limită de timp.\n\n- Dificultatea sarcinilor crește în funcție de cât de repede le rezolvi.\n\n- Concentrează-te pe a rezolva corect și atinge cel mai înalt rang posibil.", + "helpDialogTimeFrenzy": "- Ai 3 minute pentru a rezolva cât mai multe sarcini posibil.\n\n- Sarcinile devin tot mai dificile pe măsură ce le rezolvi.\n\n- Dacă faci 3 greșeli, ești eliminat.", + "hideTask": "Elimină din greșeli", + "home": "Acasă", + "komi": "Komi", + "language": "Limbă", + "leave": "Părăsește", + "light": "Luminos", + "login": "Autentificare", + "logout": "Deconectare", + "long": "Lung", + "mMinutes": "{m}min", + "@mMinutes": { + "placeholders": { + "m": { + "type": "int" + } + } + }, + "maxNumberOfMistakes": "Număr maxim de greșeli", + "maxRank": "Rang maxim", + "medium": "Mediu", + "minRank": "Rang minim", + "minutes": "Minute", + "month": "Lună", + "msgCannotUseAIRefereeYet": "Arbitrul AI nu poate fi folosit încă", + "msgCannotUseForcedCountingYet": "Numărarea forțată nu poate fi folosită încă", + "msgConfirmDeleteCollectionProgress": "Ești sigur că vrei să ștergi încercarea anterioară?", + "msgConfirmResignation": "Ești sigur că vrei să renunți?", + "msgConfirmStopEvent": "Ești sigur că vrei să oprești {event}?", + "@msgConfirmStopEvent": { + "placeholders": { + "event": { + "type": "String" + } + } + }, + "msgDownloadingGame": "Se descarcă jocul", + "msgGameSavedTo": "Joc salvat în {path}", + "@msgGameSavedTo": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "msgPleaseWaitForYourTurn": "Te rog, așteaptă-ți rândul", + "msgSearchingForGame": "Se caută un joc...", + "msgSgfCopied": "SGF copiat în clipboard", + "msgTaskLinkCopied": "Linkul sarcinii a fost copiat.", + "msgWaitingForOpponentsDecision": "Se așteaptă decizia adversarului...", + "msgYouCannotPass": "Nu poți pasa", + "msgYourOpponentDisagreesWithCountingResult": "Adversarul tău nu este de acord cu rezultatul numărării", + "msgYourOpponentRefusesToCount": "Adversarul tău refuză să numere", + "msgYourOpponentRequestsAutomaticCounting": "Adversarul tău solicită numărarea automată. Ești de acord?", + "myGames": "Jocurile mele", + "myMistakes": "Greșelile mele", + "nTasks": "{count, plural, =0{Nicio sarcină} =1{1 sarcină} other{{count} sarcini}}", + "@nTasks": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "nTasksAvailable": "{count, plural, =0{Nicio sarcină disponibilă} =1{1 sarcină disponibilă} other{{count} sarcini disponibile}}", + "@nTasksAvailable": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "newBestResult": "Cel mai bun rezultat nou!", + "no": "Nu", + "none": "Niciuna", + "numberOfTasks": "Număr de sarcini", + "nxnBoardSize": "{n}×{n}", + "@nxnBoardSize": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "ogsDesc": "Un server internațional, cel mai popular în Europa și Americi.", + "ogsName": "Serverul Online Go", + "ok": "OK", + "pass": "Pasează", + "password": "Parolă", + "play": "Joacă", + "pleaseMarkDeadStones": "Te rog marchează pietrele moarte.", + "promotionRequirements": "Cerințe pentru promovare", + "pxsByoyomi": "{p}×{s}s", + "@pxsByoyomi": { + "placeholders": { + "p": { + "type": "int" + }, + "s": { + "type": "int" + } + } + }, + "rank": "Rang", + "rankedMode": "Mod clasament", + "recentRecord": "Rezultate recente", + "register": "Înregistrare", + "rejectDeadStones": "Respinge pietrele moarte", + "resign": "Renunță", + "responseDelay": "Întârziere răspuns", + "responseDelayDesc": "Durata întârzierii înainte ca răspunsul să apară în timpul rezolvării sarcinilor", + "responseDelayLong": "Lungă", + "responseDelayMedium": "Medie", + "responseDelayNone": "Fără", + "responseDelayShort": "Scurtă", + "result": "Rezultat", + "resultAccept": "Acceptă", + "resultReject": "Respinge", + "rules": "Reguli", + "rulesChinese": "Chineze", + "rulesJapanese": "Japoneze", + "rulesKorean": "Coreene", + "sSeconds": "{s}s", + "@sSeconds": { + "placeholders": { + "s": { + "type": "int" + } + } + }, + "save": "Salvează", + "saveSGF": "Salvează SGF", + "seconds": "Secunde", + "settings": "Setări", + "short": "Scurt", + "showCoordinates": "Afișează coordonatele", + "showMoveErrorsAsCrosses": "Afișează mutările greșite ca cruci", + "showMoveErrorsAsCrossesDesc": "Afișează mutările greșite ca cruci roșii în loc de puncte roșii", + "simple": "Simplu", + "sortModeDifficult": "Dificil", + "sortModeRecent": "Recent", + "sound": "Sunet", + "start": "Start", + "statistics": "Statistici", + "statsDateColumn": "Dată", + "statsDurationColumn": "Durată", + "statsTimeColumn": "Timp", + "stoneShadows": "Umbrele pietrelor", + "stones": "Pietre", + "subtopic": "Subtemă", + "system": "Sistem", + "task": "Sarcină", + "taskCorrect": "Corect", + "taskNext": "Următorul", + "taskNotFound": "Sarcina nu a fost găsită", + "taskRedo": "Reia", + "taskSource": "Sursă sarcină", + "taskSourceFromMyMistakes": "Din greșelile mele", + "taskSourceFromTaskTopic": "Din subiectul sarcinii", + "taskSourceFromTaskTypes": "Din tipurile de sarcini", + "taskTag_afterJoseki": "După joseki", + "taskTag_aiOpening": "Deschidere AI", + "taskTag_aiVariations": "Variații AI", + "taskTag_attack": "Atac", + "taskTag_attackAndDefenseInKo": "Atac și apărare într-un ko", + "taskTag_attackAndDefenseOfCuts": "Atac și apărare la tăieturi", + "taskTag_attackAndDefenseOfInvadingStones": "Atac și apărare ale pietrelor invadatoare", + "taskTag_avoidKo": "Evită ko-ul", + "taskTag_avoidMakingDeadShape": "Evită formarea unei forme moarte", + "taskTag_avoidTrap": "Evită capcana", + "taskTag_basicEndgame": "Final: elementar", + "taskTag_basicLifeAndDeath": "Viață și moarte: elementar", + "taskTag_basicMoves": "Mutări de bază", + "taskTag_basicTesuji": "Tesuji de bază", + "taskTag_beginner": "Începător", + "taskTag_bend": "Îndoire", + "taskTag_bentFour": "Patru îndoite", + "taskTag_bentFourInTheCorner": "Patru îndoite în colț", + "taskTag_bentThree": "Trei îndoite", + "taskTag_bigEyeLiberties": "Libertățile unui ochi mare", + "taskTag_bigEyeVsSmallEye": "Ochi mare vs ochi mic", + "taskTag_bigPoints": "Puncte mari", + "taskTag_blindSpot": "Punct mort", + "taskTag_breakEye": "Distruge ochiul", + "taskTag_breakEyeInOneStep": "Distruge ochiul dintr-o singură mutare", + "taskTag_breakEyeInSente": "Distruge ochiul în sente", + "taskTag_breakOut": "Evadează", + "taskTag_breakPoints": "Distruge punctele", + "taskTag_breakShape": "Distruge forma", + "taskTag_bridgeUnder": "Pod dedesubt", + "taskTag_brilliantSequence": "Secvență strălucită", + "taskTag_bulkyFive": "Cinci voluminoase", + "taskTag_bump": "Împinge", + "taskTag_captureBySnapback": "Captură prin snapback", + "taskTag_captureInLadder": "Captură în scară", + "taskTag_captureInOneMove": "Captură într-o mutare", + "taskTag_captureOnTheSide": "Captură pe margine", + "taskTag_captureToLive": "Captură pentru a trăi", + "taskTag_captureTwoRecaptureOne": "Capturează două, recapturează una", + "taskTag_capturingRace": "Cursă de capturare", + "taskTag_capturingTechniques": "Tehnici de capturare", + "taskTag_carpentersSquareAndSimilar": "Pătratul tâmplarului și forme similare", + "taskTag_chooseTheFight": "Alege lupta", + "taskTag_clamp": "Prindere (clamp)", + "taskTag_clampCapture": "Captură prin prindere", + "taskTag_closeInCapture": "Captură prin închidere", + "taskTag_combination": "Combinație", + "taskTag_commonLifeAndDeath": "Viață și moarte: forme comune", + "taskTag_compareSize": "Compară dimensiunea", + "taskTag_compareValue": "Compară valoarea", + "taskTag_completeKoToSecureEndgameAdvantage": "Finalizează ko-ul pentru a asigura un avantaj în final", + "taskTag_compositeProblems": "Sarcini compuse", + "taskTag_comprehensiveTasks": "Sarcini cuprinzătoare", + "taskTag_connect": "Conectează", + "taskTag_connectAndDie": "Conectează și mori", + "taskTag_connectInOneMove": "Conectează dintr-o mutare", + "taskTag_contactFightTesuji": "Tesuji de luptă prin contact", + "taskTag_contactPlay": "Mutare de contact", + "taskTag_corner": "Colț", + "taskTag_cornerIsGoldSideIsSilverCenterIsGrass": "Colțul e aur, marginea e argint, centrul e iarbă", + "taskTag_counter": "Contra", + "taskTag_counterAttack": "Contraatac", + "taskTag_cranesNest": "Cuibul cocorului", + "taskTag_crawl": "Târâre", + "taskTag_createShortageOfLiberties": "Creează lipsă de libertăți", + "taskTag_crossedFive": "Cinci încrucișate", + "taskTag_cut": "Taie", + "taskTag_cut2": "Taie", + "taskTag_cutAcross": "Taie de-a curmezișul", + "taskTag_defendFromInvasion": "Apără de invazie", + "taskTag_defendPoints": "Apără punctele", + "taskTag_defendWeakPoint": "Apără punctul slab", + "taskTag_descent": "Coborâre", + "taskTag_diagonal": "Diagonală", + "taskTag_directionOfCapture": "Direcția capturii", + "taskTag_directionOfEscape": "Direcția evadării", + "taskTag_directionOfPlay": "Direcția jocului", + "taskTag_doNotUnderestimateOpponent": "Nu-ți subestima adversarul", + "taskTag_doubleAtari": "Dublu atari", + "taskTag_doubleCapture": "Captură dublă", + "taskTag_doubleKo": "Ko dublu", + "taskTag_doubleSenteEndgame": "Final dublu sente", + "taskTag_doubleSnapback": "Snapback dublu", + "taskTag_endgame": "Final: general", + "taskTag_endgameFundamentals": "Bazele finalului", + "taskTag_endgameIn5x5": "Final pe 5x5", + "taskTag_endgameOn4x4": "Final pe 4x4", + "taskTag_endgameTesuji": "Tesuji de final", + "taskTag_engulfingAtari": "Atari de învăluire", + "taskTag_escape": "Evadare", + "taskTag_escapeInOneMove": "Evadare dintr-o mutare", + "taskTag_exploitShapeWeakness": "Exploatează slăbiciunea formei", + "taskTag_eyeVsNoEye": "Ochi vs fără ochi", + "taskTag_fillNeutralPoints": "Umple punctele neutre", + "taskTag_findTheRoot": "Găsește rădăcina", + "taskTag_firstLineBrilliantMove": "Mutare strălucită pe prima linie", + "taskTag_flowerSix": "Șase în formă de floare", + "taskTag_goldenChickenStandingOnOneLeg": "Cocoșul de aur stând pe un picior", + "taskTag_groupLiberties": "Libertățile grupului", + "taskTag_groupsBase": "Baza grupului", + "taskTag_hane": "Hane", + "taskTag_increaseEyeSpace": "Mărește spațiul ochiului", + "taskTag_increaseLiberties": "Mărește libertățile", + "taskTag_indirectAttack": "Atac indirect", + "taskTag_influenceKeyPoints": "Puncte-cheie de influență", + "taskTag_insideKill": "Ucidere din interior", + "taskTag_insideMoves": "Mutări interioare", + "taskTag_interestingTasks": "Sarcini interesante", + "taskTag_internalLibertyShortage": "Lipsă internă de libertăți", + "taskTag_invadingTechnique": "Tehnică de invazie", + "taskTag_invasion": "Invazie", + "taskTag_jGroupAndSimilar": "Grupul J și forme similare", + "taskTag_josekiFundamentals": "Bazele joseki", + "taskTag_jump": "Salt", + "taskTag_keepSente": "Păstrează sente", + "taskTag_killAfterCapture": "Ucide după captură", + "taskTag_killByEyePointPlacement": "Ucide prin plasarea pe punctul ochiului", + "taskTag_knightsMove": "Mutarea calului", + "taskTag_ko": "Ko", + "taskTag_kosumiWedge": "Pană kosumi", + "taskTag_largeKnightsMove": "Mutarea mare a calului", + "taskTag_largeMoyoFight": "Luptă în moyo mare", + "taskTag_lifeAndDeath": "Viață și moarte: general", + "taskTag_lifeAndDeathOn4x4": "Viață și moarte pe 4x4", + "taskTag_lookForLeverage": "Caută avantajul (leverage)", + "taskTag_looseLadder": "Scară lejeră", + "taskTag_lovesickCut": "Tăietură a îndrăgostitului", + "taskTag_makeEye": "Creează un ochi", + "taskTag_makeEyeInOneStep": "Creează un ochi dintr-o mutare", + "taskTag_makeEyeInSente": "Creează un ochi în sente", + "taskTag_makeKo": "Creează un ko", + "taskTag_makeShape": "Creează formă", + "taskTag_middlegame": "Joc de mijloc", + "taskTag_monkeyClimbingMountain": "Maimuța urcând muntele", + "taskTag_mouseStealingOil": "Șoarecele furând ulei", + "taskTag_moveOut": "Ieși afară", + "taskTag_moveTowardsEmptySpace": "Mută-te spre spațiul gol", + "taskTag_multipleBrilliantMoves": "Mai multe mutări strălucite", + "taskTag_net": "Plasă", + "taskTag_netCapture": "Captură prin plasă", + "taskTag_observeSubtleDifference": "Observă diferența subtilă", + "taskTag_occupyEncloseAndApproachCorner": "Ocupă, închide și abordează colțurile", + "taskTag_oneStoneTwoPurposes": "O piatră, două scopuri", + "taskTag_opening": "Deschidere", + "taskTag_openingChoice": "Alegerea deschiderii", + "taskTag_openingFundamentals": "Bazele deschiderii", + "taskTag_orderOfEndgameMoves": "Ordinea mutărilor în final", + "taskTag_orderOfMoves": "Ordinea mutărilor", + "taskTag_orderOfMovesInKo": "Ordinea mutărilor într-un ko", + "taskTag_orioleCapturesButterfly": "Privighetoarea capturează fluturele", + "taskTag_pincer": "Clește (pincer)", + "taskTag_placement": "Plasare", + "taskTag_plunderingTechnique": "Tehnică de jefuire", + "taskTag_preventBambooJoint": "Previne îmbinarea de bambus", + "taskTag_preventBridgingUnder": "Previne podul dedesubt", + "taskTag_preventOpponentFromApproaching": "Împiedică adversarul să se apropie", + "taskTag_probe": "Sondaj (probe)", + "taskTag_profitInSente": "Profit în sente", + "taskTag_profitUsingLifeAndDeath": "Profit folosind viață și moarte", + "taskTag_push": "Împinge", + "taskTag_pyramidFour": "Piramidă de patru", + "taskTag_realEyeAndFalseEye": "Ochi real vs ochi fals", + "taskTag_rectangularSix": "Șase dreptunghiulare", + "taskTag_reduceEyeSpace": "Reduce spațiul ochiului", + "taskTag_reduceLiberties": "Reduce libertățile", + "taskTag_reduction": "Reducere", + "taskTag_runWeakGroup": "Fă grupul slab să fugă", + "taskTag_sabakiAndUtilizingInfluence": "Sabaki și utilizarea influenței", + "taskTag_sacrifice": "Sacrificiu", + "taskTag_sacrificeAndSqueeze": "Sacrificiu și strângere", + "taskTag_sealIn": "Încercuiește (sigilează înăuntru)", + "taskTag_secondLine": "A doua linie", + "taskTag_seizeTheOpportunity": "Prinde oportunitatea", + "taskTag_seki": "Seki", + "taskTag_senteAndGote": "Sente și gote", + "taskTag_settleShape": "Stabilizează forma", + "taskTag_settleShapeInSente": "Stabilizează forma în sente", + "taskTag_shape": "Formă", + "taskTag_shapesVitalPoint": "Punctul vital al formei", + "taskTag_side": "Margine", + "taskTag_smallBoardEndgame": "Final pe tablă mică", + "taskTag_snapback": "Snapback", + "taskTag_solidConnection": "Conectare solidă", + "taskTag_solidExtension": "Extensie solidă", + "taskTag_splitInOneMove": "Desparte dintr-o mutare", + "taskTag_splittingMove": "Mutare de separare", + "taskTag_squareFour": "Pătrat de patru", + "taskTag_squeeze": "Strângere", + "taskTag_standardCapturingRaces": "Cursă standard de capturare", + "taskTag_standardCornerAndSideEndgame": "Final standard de colț și margine", + "taskTag_straightFour": "Patru în linie", + "taskTag_straightThree": "Trei în linie", + "taskTag_surroundTerritory": "Încercuiește teritoriul", + "taskTag_symmetricShape": "Formă simetrică", + "taskTag_techniqueForReinforcingGroups": "Tehnică de consolidare a grupurilor", + "taskTag_techniqueForSecuringTerritory": "Tehnică pentru asigurarea teritoriului", + "taskTag_textbookTasks": "Sarcini din manual", + "taskTag_thirdAndFourthLine": "Linia a treia și a patra", + "taskTag_threeEyesTwoActions": "Trei ochi, două acțiuni", + "taskTag_threeSpaceExtensionFromTwoStones": "Extensie de trei spații de la două pietre", + "taskTag_throwIn": "Aruncare înăuntru (throw-in)", + "taskTag_tigersMouth": "Gura tigrului", + "taskTag_tombstoneSqueeze": "Strângerea pietrei funerare", + "taskTag_tripodGroupWithExtraLegAndSimilar": "Grup tripod cu picior suplimentar și forme similare", + "taskTag_twoHaneGainOneLiberty": "Dublu hane câștigă o libertate", + "taskTag_twoHeadedDragon": "Dragon cu două capete", + "taskTag_twoSpaceExtension": "Extensie de două spații", + "taskTag_typesOfKo": "Tipuri de ko", + "taskTag_underTheStones": "Sub pietre", + "taskTag_underneathAttachment": "Atașare dedesubt", + "taskTag_urgentPointOfAFight": "Punct urgent al luptei", + "taskTag_urgentPoints": "Puncte urgente", + "taskTag_useConnectAndDie": "Folosește conectează și mori", + "taskTag_useCornerSpecialProperties": "Folosește proprietățile speciale ale colțului", + "taskTag_useDescentToFirstLine": "Folosește coborârea pe prima linie", + "taskTag_useInfluence": "Folosește influența", + "taskTag_useOpponentsLifeAndDeath": "Folosește viața și moartea adversarului", + "taskTag_useShortageOfLiberties": "Folosește lipsa de libertăți", + "taskTag_useSnapback": "Folosește snapback", + "taskTag_useSurroundingStones": "Folosește pietrele din jur", + "taskTag_vitalAndUselessStones": "Pietre vitale și inutile", + "taskTag_vitalPointForBothSides": "Punct vital pentru ambele părți", + "taskTag_vitalPointForCapturingRace": "Punct vital pentru cursa de capturare", + "taskTag_vitalPointForIncreasingLiberties": "Punct vital pentru creșterea libertăților", + "taskTag_vitalPointForKill": "Punct vital pentru ucidere", + "taskTag_vitalPointForLife": "Punct vital pentru viață", + "taskTag_vitalPointForReducingLiberties": "Punct vital pentru reducerea libertăților", + "taskTag_wedge": "Pană", + "taskTag_wedgingCapture": "Captură prin pană", + "taskTimeout": "Timp expirat", + "taskTypeAppreciation": "Apreciere", + "taskTypeCapture": "Capturare de pietre", + "taskTypeCaptureRace": "Cursă de capturare", + "taskTypeEndgame": "Final", + "taskTypeJoseki": "Joseki", + "taskTypeLifeAndDeath": "Viață și moarte", + "taskTypeMiddlegame": "Joc de mijloc", + "taskTypeOpening": "Deschidere", + "taskTypeTesuji": "Tesuji", + "taskTypeTheory": "Teorie", + "taskWrong": "Greșit", + "tasksSolved": "Sarcini rezolvate", + "test": "Test", + "theme": "Temă", + "thick": "Gros", + "timeFrenzy": "Go contra cronometru", + "timeFrenzyMistakes": "Urmărește greșelile din Go contra cronometru", + "timeFrenzyMistakesDesc": "Activează pentru a salva greșelile făcute în Go contra cronometru", + "randomizeTaskOrientation": "Orientare aleatoare a tzumego", + "randomizeTaskOrientationDesc": "Rotește și reflectă aleator problemele de tsumego de-a lungul axelor orizontale, verticale și diagonale pentru a preveni memorarea și a îmbunătăți recunoașterea modelelor.", + "timePerTask": "Timp per sarcină", + "today": "Astăzi", + "tooltipAnalyzeWithAISensei": "Analizează cu AI Sensei", + "tooltipDownloadGame": "Descarcă jocul", + "topic": "Subiect", + "topicExam": "Examen pe subiect", + "topics": "Subiecte", + "train": "Antrenează-te", + "trainingAvgTimePerTask": "Timp mediu per sarcină", + "trainingFailed": "Eșuat", + "trainingMistakes": "Greșeli", + "trainingPassed": "Promovat", + "trainingTotalTime": "Timp total", + "tryCustomMoves": "Încearcă mutări personalizate", + "tygemDesc": "Cel mai popular server din Coreea și unul dintre cele mai populare din lume.", + "tygemName": "Tygem Baduk", + "type": "Tip", + "ui": "Interfață utilizator", + "userInfo": "Informații utilizator", + "username": "Nume de utilizator", + "voice": "Voce", + "week": "Săptămână", + "white": "Alb", + "yes": "Da" +} \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 00000000..817804ac --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,518 @@ +{ + "about": "О приложении", + "acceptDeadStones": "Принять мёртвые камни", + "accuracy": "Точность", + "aiReferee": "ИИ-судья", + "aiSensei": "AI Sensei", + "alwaysBlackToPlay": "Всегда ход чёрных", + "alwaysBlackToPlayDesc": "Устанавливать все задачи как ход чёрных, чтобы избежать путаницы", + "appearance": "Интерфейс", + "autoCounting": "Автоподсчёт", + "autoMatch": "Автоподбор", + "behaviour": "Поведение", + "bestResult": "Лучший результат", + "black": "Чёрные", + "board": "Доска", + "boardSize": "Размер доски", + "boardTheme": "Тема доски", + "byRank": "По рангу", + "cancel": "Отмена", + "captures": "Захваты", + "clearBoard": "Очистить", + "collectStats": "Добавлять в статистику", + "collections": "Коллекции", + "confirm": "Подтвердить", + "confirmBoardSize": "Минимальный размер доски", + "confirmBoardSizeDesc": "Подтверждение ходов требуется для досок этого размера и больше", + "confirmMoves": "Подтверждение ходов", + "confirmMovesDesc": "Двойное нажатие на один и тот же пункт для подтверждения хода, чтобы избежать случайных нажатий", + "continue_": "Продолжить", + "copySGF": "Копировать SGF", + "copyTaskLink": "Копировать ссылку на задачу", + "customExam": "Пользовательский экзамен", + "dark": "Тёмная", + "dontShowAgain": "Больше не показывать", + "download": "Скачать", + "edgeLine": "Линия края", + "empty": "Пусто", + "endgameExam": "Экзамены по йосе", + "enterTaskLink": "Введите ссылку на задачу", + "errCannotBeEmpty": "Обязательное поле", + "errFailedToDownloadGame": "Не удалось скачать партию", + "errFailedToLoadGameList": "Не удалось загрузить список партий. Пожалуйста, попробуйте снова.", + "errFailedToUploadGameToAISensei": "Не удалось загрузить партию в AI Sensei", + "errIncorrectUsernameOrPassword": "Неверное имя пользователя или пароль", + "errMustBeAtLeast": "Должно быть не менее {n}", + "@errMustBeAtLeast": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeAtMost": "Должно быть не более {n}", + "@errMustBeAtMost": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeInteger": "Должно быть целым числом", + "exit": "Выход", + "exitTryMode": "Выйти из режима пробных ходов", + "find": "Найти", + "findTask": "Найти задачу", + "findTaskByLink": "По ссылке", + "findTaskByPattern": "По позиции", + "findTaskResults": "Результаты поиска", + "findTaskSearching": "Поиск...", + "forceCounting": "Принудительный подсчёт", + "foxwqDesc": "Самый популярный сервер в Китае и в мире.", + "foxwqName": "Фокс Вэйци", + "gameInfo": "Информация о партии", + "gameRecord": "Запись партии", + "gradingExam": "Контрольный экзамен", + "handicap": "Фора", + "help": "Помощь", + "helpDialogCollections": "Коллекции — это классические, тщательно отобранные наборы высококачественных задач, которые вместе представляют особую ценность как учебный ресурс.\n\nОсновная цель — решить коллекцию с высоким процентом успеха. Вторичная цель — решить её как можно быстрее.\n\nЧтобы начать или продолжить решение коллекции, смахните влево по плитке коллекции в портретном режиме или нажмите кнопки «Начать»/«Продолжить» в ландшафтном режиме.", + "helpDialogEndgameExam": "- Экзамены по йосе — это наборы из 10 задач по йосе, и у вас есть 45 секунд на каждую задачу.\n\n- Вы сдаёте экзамен, если решаете 8 или более задач правильно (80% успеха).\n\n- Сдача экзамена для данного ранга открывает экзамен для следующего ранга.", + "helpDialogGradingExam": "- Контрольные экзамены — это наборы из 10 задач, и у вас есть 45 секунд на каждую задачу.\n\n- Вы сдаёте экзамен, если решаете 8 или более задач правильно (80% успеха).\n\n- Сдача экзамена для данного ранга открывает экзамен для следующего ранга.", + "helpDialogRankedMode": "- Решайте задачи без ограничения по времени.\n\n- Сложность задач увеличивается в зависимости от того, насколько быстро вы их решаете.\n\n- Сосредоточьтесь на правильном решении и достигните максимально возможного ранга.", + "helpDialogTimeFrenzy": "- У вас есть 3 минуты, чтобы решить как можно больше задач.\n\n- Задачи становятся всё сложнее по мере их решения.\n\n- Если вы допустите 3 ошибки, вы проиграете.", + "hideTask": "Удалить из ошибок", + "home": "Главная", + "komi": "Коми", + "language": "Язык", + "leave": "Покинуть", + "light": "Светлая", + "login": "Войти", + "logout": "Выйти", + "long": "Длинная", + "mMinutes": "{m} мин", + "@mMinutes": { + "placeholders": { + "m": { + "type": "int" + } + } + }, + "maxNumberOfMistakes": "Максимальное количество ошибок", + "maxRank": "Макс. ранг", + "medium": "Средняя", + "minRank": "Мин. ранг", + "minutes": "Минуты", + "month": "Месяц", + "msgCannotUseAIRefereeYet": "ИИ-судья ещё нельзя использовать", + "msgCannotUseForcedCountingYet": "Принудительный подсчёт ещё нельзя использовать", + "msgConfirmDeleteCollectionProgress": "Вы уверены, что хотите начать коллекцию заново? Текущий прогресс будет удалён.", + "msgConfirmResignation": "Вы уверены, что хотите сдаться?", + "msgConfirmStopEvent": "Вы уверены, что хотите остановить {event}?", + "@msgConfirmStopEvent": { + "placeholders": { + "event": { + "type": "String" + } + } + }, + "msgDownloadingGame": "Скачивание партии", + "msgGameSavedTo": "Партия сохранена в {path}", + "@msgGameSavedTo": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "msgPleaseWaitForYourTurn": "Пожалуйста, дождитесь своего хода", + "msgSearchingForGame": "Поиск партии...", + "msgSgfCopied": "SGF скопирован в буфер обмена", + "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": "Играть", + "pleaseMarkDeadStones": "Пожалуйста, отметьте мёртвые камни.", + "promotionRequirements": "Требования для повышения", + "pxsByoyomi": "{p}×{s}с", + "@pxsByoyomi": { + "placeholders": { + "p": { + "type": "int" + }, + "s": { + "type": "int" + } + } + }, + "rank": "Ранг", + "rankedMode": "Рейтинговый режим", + "recentRecord": "Недавние результаты", + "register": "Зарегистрироваться", + "rejectDeadStones": "Отклонить мёртвые камни", + "resign": "Сдаться", + "responseDelay": "Задержка ответа", + "responseDelayDesc": "Задержка перед показом ответного хода при решении задач", + "responseDelayLong": "Длинная", + "responseDelayMedium": "Средняя", + "responseDelayNone": "Нет", + "responseDelayShort": "Короткая", + "result": "Результат", + "resultAccept": "Принять", + "resultReject": "Отказаться", + "rules": "Правила", + "rulesChinese": "Китайские", + "rulesJapanese": "Японские", + "rulesKorean": "Корейские", + "sSeconds": "{s} с", + "@sSeconds": { + "placeholders": { + "s": { + "type": "int" + } + } + }, + "save": "Сохранить", + "saveSGF": "Сохранить SGF", + "seconds": "Секунды", + "settings": "Настройки", + "short": "Короткая", + "showCoordinates": "Показывать координаты", + "showMoveErrorsAsCrosses": "Отображать неправильные ходы крестами", + "showMoveErrorsAsCrossesDesc": "Отображать неправильные ходы красными крестами вместо красных точек", + "simple": "Простая", + "sortModeDifficult": "Сложные", + "sortModeRecent": "Недавние", + "sound": "Звук", + "start": "Начать", + "statistics": "Статистика", + "statsDateColumn": "Дата", + "statsDurationColumn": "Время", + "statsTimeColumn": "Время", + "stoneShadows": "Тени камней", + "stones": "Камни", + "subtopic": "Подтема", + "system": "Система", + "task": "Задача", + "taskCorrect": "Верно", + "taskNext": "Следующий", + "taskNotFound": "Задача не найдена", + "taskRedo": "Переделать", + "taskSource": "Происхождение задач", + "taskSourceFromMyMistakes": "Из моих ошибок", + "taskSourceFromTaskTopic": "Из темы задач", + "taskSourceFromTaskTypes": "Из типов задач", + "taskTag_afterJoseki": "После дзёсэки", + "taskTag_aiOpening": "Фусэки от ИИ", + "taskTag_aiVariations": "Варианты от ИИ", + "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х5 йосе", + "taskTag_endgameOn4x4": "4х4 йосе", + "taskTag_endgameTesuji": "Йосе-тесуджи", + "taskTag_engulfingAtari": "Окружающее атари", + "taskTag_escape": "Побег", + "taskTag_escapeInOneMove": "Побег в один ход", + "taskTag_exploitShapeWeakness": "Использовать слабость формы", + "taskTag_eyeVsNoEye": "Группа с глазом против группы без глаза", + "taskTag_fillNeutralPoints": "Заполнение нейтральных пунктов", + "taskTag_findTheRoot": "Найти корень", + "taskTag_firstLineBrilliantMove": "Блестящий ход на 1-й линии", + "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х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": "2-я линия", + "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": "3-я и 4-я линия", + "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": "Время вышло", + "taskTypeAppreciation": "Оценка позиции", + "taskTypeCapture": "Захват камней", + "taskTypeCaptureRace": "Семеай", + "taskTypeEndgame": "Йосе", + "taskTypeJoseki": "Дзёсэки", + "taskTypeLifeAndDeath": "Жизнь и смерть", + "taskTypeMiddlegame": "Середина игры", + "taskTypeOpening": "Фусэки", + "taskTypeTesuji": "Тесуджи", + "taskTypeTheory": "Теория", + "taskWrong": "Неверно", + "tasksSolved": "Задач решено", + "test": "Тест", + "theme": "Тема", + "thick": "Толстая", + "timeFrenzy": "Временной раж", + "timeFrenzyMistakes": "Отслеживать ошибки во Временном раже", + "timeFrenzyMistakesDesc": "Включить для сохранения ошибок, сделанных во Временном раже", + "randomizeTaskOrientation": "Случайная ориентация задач", + "randomizeTaskOrientationDesc": "Случайно поворачивает и отражает задачи вдоль горизонтальной, вертикальной и диагональной осей, чтобы предотвратить запоминание и улучшить распознавание паттернов.", + "timePerTask": "Время на задачу", + "today": "Сегодня", + "tooltipAnalyzeWithAISensei": "Анализировать с AI Sensei", + "tooltipDownloadGame": "Скачать партию", + "topic": "Тема", + "topicExam": "Тематический экзамен", + "topics": "Темы", + "train": "Тренироваться", + "trainingAvgTimePerTask": "Ср. время на задачу", + "trainingFailed": "Не сдано", + "trainingMistakes": "Ошибки", + "trainingPassed": "Сдано", + "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..0d681c20 --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,518 @@ +{ + "about": "关于", + "acceptDeadStones": "确认死子", + "accuracy": "答题准确率", + "aiReferee": "AI 裁判", + "aiSensei": "AI Sensei", + "alwaysBlackToPlay": "总是执黑先行", + "alwaysBlackToPlayDesc": "永远执黑先行,防止混淆", + "appearance": "外观", + "autoCounting": "自动点目", + "autoMatch": "自动匹配", + "behaviour": "操作", + "bestResult": "最优结果", + "black": "黑", + "board": "棋盘", + "boardSize": "棋盘颜色", + "boardTheme": "棋盘主题", + "byRank": "按段位", + "cancel": "取消", + "captures": "吃子", + "clearBoard": "清空", + "collectStats": "计入统计", + "collections": "棋书", + "confirm": "确认", + "confirmBoardSize": "确认棋盘大小", + "confirmBoardSizeDesc": "此尺寸及更大的棋盘需要确认落子", + "confirmMoves": "确定落子", + "confirmMovesDesc": "在较大的棋盘上双击以确认落子,避免误操作", + "continue_": "继续", + "copySGF": "复制SGF", + "copyTaskLink": "复制题目链接", + "customExam": "自定义测试", + "dark": "深色", + "dontShowAgain": "不再显示", + "download": "下载", + "edgeLine": "棋盘边缘", + "empty": "空", + "endgameExam": "官子测试", + "enterTaskLink": "输入题目链接", + "errCannotBeEmpty": "不能为空", + "errFailedToDownloadGame": "下载棋局失败", + "errFailedToLoadGameList": "加载棋局列表失败,请重试。", + "errFailedToUploadGameToAISensei": "上传棋局到 AI Sensei 失败", + "errIncorrectUsernameOrPassword": "用户名或密码错误", + "errMustBeAtLeast": "必须至少{n}", + "@errMustBeAtLeast": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeAtMost": "必须最多{n}", + "@errMustBeAtMost": { + "placeholders": { + "n": { + "type": "num" + } + } + }, + "errMustBeInteger": "必须为整数", + "exit": "退出", + "exitTryMode": "退出试下模式", + "find": "查找", + "findTask": "搜题", + "findTaskByLink": "链接搜题", + "findTaskByPattern": "棋形搜题", + "findTaskResults": "搜题结果", + "findTaskSearching": "搜题中...", + "forceCounting": "强制数子", + "foxwqDesc": "野狐围棋是精心打造的专业围棋对弈、社交软件。", + "foxwqName": "Fox Weiqi", + "gameInfo": "信息", + "gameRecord": "棋谱", + "gradingExam": "棋力测试", + "handicap": "让子数", + "help": "帮助", + "helpDialogCollections": "棋书是经典的、精选的高质量题目合集,作为训练资源具有特殊价值。\n\n主要目标是以高成功率解完一本棋书。次要目标是尽可能快地完成。\n\n要开始或继续解答棋书,请在竖屏模式下向左滑动棋书图块,或在横屏模式下点击「开始」/「继续」按钮。", + "helpDialogEndgameExam": "- 官子测试包含10道官子题,每题限时45秒。\n\n- 答对8题及以上(80%正确率)即为通过。\n\n- 通过当前级别测试将解锁下一级别。", + "helpDialogGradingExam": "- 棋力测试包含10道题,每题限时45秒。\n\n- 答对8题及以上(80%正确率)即为通过。\n\n- 通过当前级别测试将解锁下一级别。", + "helpDialogRankedMode": "- 无时间限制答题。\n\n- 题目难度会根据您的解题速度动态调整。\n\n- 专注于正确答题,冲击最高等级。", + "helpDialogTimeFrenzy": "- 3分钟内解答尽可能多的题。\n\n- 题目难度会随着您的进度逐渐增加。\n\n- 累计答错3题即结束。", + "hideTask": "移出错题集", + "home": "主界面", + "komi": "贴目", + "language": "语言", + "leave": "退出房间", + "light": "浅色", + "login": "登录", + "logout": "退出", + "long": "长", + "mMinutes": "{m}分钟", + "@mMinutes": { + "placeholders": { + "m": { + "type": "int" + } + } + }, + "maxNumberOfMistakes": "最大错误数", + "maxRank": "最高段位", + "medium": "中", + "minRank": "最低段位", + "minutes": "分", + "month": "月", + "msgCannotUseAIRefereeYet": "您还不能使用 AI 裁判", + "msgCannotUseForcedCountingYet": "手数不足,不可强制数子或强制点目", + "msgConfirmDeleteCollectionProgress": "确定要删除之前的棋书进度吗?", + "msgConfirmResignation": "确认认输?", + "msgConfirmStopEvent": "确定要停止{event}吗?", + "@msgConfirmStopEvent": { + "placeholders": { + "event": { + "type": "String" + } + } + }, + "msgDownloadingGame": "下载棋局中", + "msgGameSavedTo": "棋局已保存至{path}", + "@msgGameSavedTo": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "msgPleaseWaitForYourTurn": "请等待您的回合", + "msgSearchingForGame": "正在寻找对局...", + "msgSgfCopied": "SGF已复制到剪贴板", + "msgTaskLinkCopied": "题目链接已复制", + "msgWaitingForOpponentsDecision": "等待决定...", + "msgYouCannotPass": "本局您已经不能再停一手了。", + "msgYourOpponentDisagreesWithCountingResult": "有人不同意点目结果。", + "msgYourOpponentRefusesToCount": "对方不同意点目。", + "msgYourOpponentRequestsAutomaticCounting": "对方要求自动点目,是否同意?", + "myGames": "我的棋局", + "myMistakes": "我的错题本", + "nTasks": "{count, plural, =0{无题} other{{count}题}}", + "@nTasks": { + "placeholders": { + "count": { + "format": "decimalPattern", + "type": "int" + } + } + }, + "nTasksAvailable": "{count, plural, =0{无题} 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": "对局", + "pleaseMarkDeadStones": "请标记死子。", + "promotionRequirements": "升级要求", + "pxsByoyomi": "{p}×{s}s", + "@pxsByoyomi": { + "placeholders": { + "p": { + "type": "int" + }, + "s": { + "type": "int" + } + } + }, + "rank": "段位", + "rankedMode": "等级模式", + "recentRecord": "当前战绩", + "register": "创建帐号", + "rejectDeadStones": "取消死子", + "resign": "认输并退出", + "responseDelay": "响应延迟", + "responseDelayDesc": "解题时响应前的延迟时长", + "responseDelayLong": "长", + "responseDelayMedium": "中", + "responseDelayNone": "无", + "responseDelayShort": "短", + "result": "结果", + "resultAccept": "接受", + "resultReject": "拒绝", + "rules": "规则", + "rulesChinese": "中国规则", + "rulesJapanese": "日本规则", + "rulesKorean": "韩国规则", + "sSeconds": "{s}秒", + "@sSeconds": { + "placeholders": { + "s": { + "type": "int" + } + } + }, + "save": "保存", + "saveSGF": "保存SGF", + "seconds": "秒", + "settings": "设置", + "short": "短", + "showCoordinates": "显示坐标", + "showMoveErrorsAsCrosses": "显示错误着法为叉号", + "showMoveErrorsAsCrossesDesc": "将错误着法显示为红色叉号而非红色圆点", + "simple": "普通", + "sortModeDifficult": "困难", + "sortModeRecent": "最近", + "sound": "声音", + "start": "开始", + "statistics": "统计", + "statsDateColumn": "日期", + "statsDurationColumn": "用时", + "statsTimeColumn": "时间", + "stoneShadows": "棋子阴影", + "stones": "棋子", + "subtopic": "子主题", + "system": "系统", + "task": "题", + "taskCorrect": "答对了!", + "taskNext": "下一题", + "taskNotFound": "未找到该题", + "taskRedo": "重做题", + "taskSource": "题库来源", + "taskSourceFromMyMistakes": "来自我的错题", + "taskSourceFromTaskTopic": "来自专题题库", + "taskSourceFromTaskTypes": "来自题型分类", + "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": "答题超时", + "taskTypeAppreciation": "欣赏题", + "taskTypeCapture": "吃子题", + "taskTypeCaptureRace": "对杀题", + "taskTypeEndgame": "官子题", + "taskTypeJoseki": "定式题", + "taskTypeLifeAndDeath": "死活题", + "taskTypeMiddlegame": "中盘作战题", + "taskTypeOpening": "布局题", + "taskTypeTesuji": "手筋题", + "taskTypeTheory": "棋理题", + "taskWrong": "答错了", + "tasksSolved": "解题数", + "test": "测试", + "theme": "主题", + "thick": "粗线", + "timeFrenzy": "限时挑战", + "timeFrenzyMistakes": "记录限时挑战错误", + "timeFrenzyMistakesDesc": "启用以保存限时挑战中的错误", + "randomizeTaskOrientation": "随机化题目方向", + "randomizeTaskOrientationDesc": "随机旋转和翻转题目,沿水平、垂直和对角轴线,防止记忆化并增强模式识别能力。", + "timePerTask": "单题用时", + "today": "今日", + "tooltipAnalyzeWithAISensei": "使用 AI Sensei 分析", + "tooltipDownloadGame": "下载棋局", + "topic": "主题", + "topicExam": "主题测试", + "topics": "主题", + "train": "练习", + "trainingAvgTimePerTask": "平均单题用时", + "trainingFailed": "未通过", + "trainingMistakes": "错误", + "trainingPassed": "通过", + "trainingTotalTime": "总用时", + "tryCustomMoves": "试下", + "tygemDesc": "Tygem 是韩国最受欢迎的围棋对弈平台,以其激烈的在线对局氛围而闻名世界。", + "tygemName": "Tygem Baduk", + "type": "类型", + "ui": "界面", + "userInfo": "用户信息", + "username": "用户名", + "voice": "声音", + "week": "周", + "white": "白", + "yes": "确定" +} \ No newline at end of file diff --git a/lib/local_board_page.dart b/lib/local_board_page.dart index e93d53e2..7204b053 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')) { + // see https://github.com/fluttercommunity/plus_plugins/issues/3645#issuecomment-3360156193 + sharePositionOrigin = Rect.fromLTWH(0, 0, 1, 1); + } + } + 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..172cfd0b 100644 --- a/lib/main_page.dart +++ b/lib/main_page.dart @@ -1,17 +1,23 @@ -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'; import 'package:wqhub/train/single_task_page.dart'; +import 'package:wqhub/train/task_pattern_search_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/ranked_mode_task_source.dart'; @@ -25,17 +31,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 +60,7 @@ class _MainPageState extends WindowClassAwareState { ? AppBar( title: Text('WeiqiHub'), actions: [ - SettingsButton(reloadAppTheme: widget.reloadAppTheme), + SettingsButton(reloadAppTheme: widget.rebuildApp), ], ) : null, @@ -71,12 +87,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 +123,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 +159,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 +174,7 @@ class _Train extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 800), @@ -160,176 +188,209 @@ 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( + randomizeLayout: + context.settings.randomizeTaskOrientation), + 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, + context.settings.randomizeTaskOrientation), + 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: () { - showDialog<(Task?, bool)>( - context: context, - builder: (context) => _FindTaskDialog(), - ).then((res) { - if (res != null) { - final (task, dismissed) = res; - if (dismissed) return; - if (task != null) { - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SingleTaskPage( - task: context.settings.alwaysBlackToPlay - ? task.withBlackToPlay() - : task)), - ); - } - } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Task not found.'), - dismissDirection: DismissDirection.horizontal, - showCloseIcon: true, - )); - } - } - } - }, onError: (err) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Task not found.'), - dismissDirection: DismissDirection.horizontal, - showCloseIcon: true, - )); - } - }); + Navigator.pushNamed(context, CustomExamSelectionPage.routeName); }, - icon: Icon(Icons.search), - label: const Text('Find task'), ), - FloatingActionButton.extended( - heroTag: null, + SectionButton( + icon: Icons.search, + label: loc.findTask, + onPressed: () => showFindTaskDialog(context), + ), + 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'), ), ], ), ), ); } + + showFindTaskDialog(BuildContext parentContext) { + final loc = AppLocalizations.of(parentContext)!; + showDialog( + context: parentContext, + builder: (context) { + return AlertDialog( + title: Text(loc.findTask), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8.0, + children: [ + ElevatedButton.icon( + icon: Icon(Icons.link), + label: Text(loc.findTaskByLink), + onPressed: () { + Navigator.of(parentContext).pop(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => showFindTaskByLinkDialog(parentContext)); + }, + ), + ElevatedButton.icon( + icon: Icon(Icons.pattern), + label: Text(loc.findTaskByPattern), + onPressed: () { + Navigator.of(parentContext).pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.pushNamed( + context, + TaskPatternSearchPage.routeName, + ); + }); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, (null, true)), + child: Text(loc.cancel), + ), + ], + ); + }, + ); + } + + void showFindTaskByLinkDialog(BuildContext context) { + showDialog<(Task?, bool)>( + context: context, + builder: (context) => _FindTaskDialog(), + ).then((res) { + if (res != null) { + var (task, dismissed) = res; + if (dismissed) return; + if (task != null) { + if (context.mounted) { + if (context.settings.alwaysBlackToPlay) { + task = task.withBlackToPlay(); + } + task = task.withRandomSymmetry( + randomize: context.settings.randomizeTaskOrientation); + + Navigator.pushNamed( + context, + SingleTaskPage.routeName, + arguments: SingleTaskRouteArguments(task: task), + ); + } + } else { + if (context.mounted) { + final loc = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(loc.taskNotFound), + dismissDirection: DismissDirection.horizontal, + showCloseIcon: true, + )); + } + } + } + }, onError: (err) { + if (context.mounted) { + final loc = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(loc.taskNotFound), + dismissDirection: DismissDirection.horizontal, + showCloseIcon: true, + )); + } + }); + } } class _FindTaskDialog extends StatefulWidget { @@ -348,27 +409,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/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/automatch_preset_list_tile.dart b/lib/play/automatch_preset_list_tile.dart new file mode 100644 index 00000000..0bf550b9 --- /dev/null +++ b/lib/play/automatch_preset_list_tile.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/game_client/automatch_preset.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; + +class AutomatchPresetListTile extends StatelessWidget { + final AutomatchPreset preset; + final Function() onTap; + + const AutomatchPresetListTile( + {super.key, required this.preset, required this.onTap}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final title = Text(preset.timeControl.mainTime.inMinutes == 0 + ? '${loc.sSeconds(preset.timeControl.mainTime.inSeconds)} ${loc.pxsByoyomi(preset.timeControl.periodCount, preset.timeControl.timePerPeriod.inSeconds)}' + : '${loc.mMinutes(preset.timeControl.mainTime.inMinutes)} ${loc.pxsByoyomi(preset.timeControl.periodCount, preset.timeControl.timePerPeriod.inSeconds)}'); + + return ListTile( + leading: Text(loc.nxnBoardSize(preset.boardSize)), + title: title, + subtitle: Text('${loc.rules}: ${preset.rules.toLocalizedString(loc)}'), + trailing: (preset.playerCount != null) + ? Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon(Icons.people), + Text(preset.playerCount.toString()), + ], + ) + : null, + onTap: onTap, + ); + } +} diff --git a/lib/play/counting_result_bottom_sheet.dart b/lib/play/counting_result_bottom_sheet.dart index 7585751e..d3e924e5 100644 --- a/lib/play/counting_result_bottom_sheet.dart +++ b/lib/play/counting_result_bottom_sheet.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; import 'package:wqhub/wq/wq.dart' as wq; class CountingResultBottomSheet extends StatelessWidget { final wq.Color winner; final double scoreLead; final Function() onAccept; - final Function() onReject; + final Function()? onReject; const CountingResultBottomSheet({ super.key, @@ -17,6 +18,7 @@ class CountingResultBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.all(8.0), child: Row( @@ -28,8 +30,8 @@ class CountingResultBottomSheet extends StatelessWidget { Expanded( child: Text('${winner.toString()}+ $scoreLead'), ), - FilledButton(onPressed: onAccept, child: const Text('Accept')), - FilledButton(onPressed: onReject, child: const Text('Reject')), + FilledButton(onPressed: onAccept, child: Text(loc.resultAccept)), + FilledButton(onPressed: onReject, child: Text(loc.resultReject)), ], ), ); diff --git a/lib/play/game_counting_bar.dart b/lib/play/game_counting_bar.dart new file mode 100644 index 00000000..41380ada --- /dev/null +++ b/lib/play/game_counting_bar.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; + +class GameCountingBar extends StatelessWidget { + final Function()? onAcceptDeadStones; + final Function() onRejectDeadStones; + final List<(String, bool)> acceptStatus; + + const GameCountingBar({ + super.key, + required this.onAcceptDeadStones, + required this.onRejectDeadStones, + required this.acceptStatus, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final acceptDeadStonesButton = ElevatedButton.icon( + onPressed: onAcceptDeadStones, + label: Text(loc.acceptDeadStones), + icon: Icon(Icons.check_circle), + ); + final rejectDeadStonesButton = ElevatedButton.icon( + onPressed: onRejectDeadStones, + label: Text(loc.rejectDeadStones), + icon: Icon(Icons.cancel), + ); + return Padding( + padding: const EdgeInsets.all(4.0), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Text(loc.pleaseMarkDeadStones), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + spacing: 4, + children: [ + if (onAcceptDeadStones != null) + Expanded(child: acceptDeadStonesButton), + Expanded(child: rejectDeadStonesButton), + ], + ), + SizedBox(height: 16.0), + for (final (nick, accepted) in acceptStatus) + Row( + mainAxisSize: MainAxisSize.min, + spacing: 4.0, + children: [ + Icon(accepted ? Icons.check_circle : Icons.cancel, + color: accepted ? Colors.green : Colors.redAccent), + Text(nick), + ], + ) + ], + ), + ); + } +} diff --git a/lib/play/game_page.dart b/lib/play/game_page.dart index 100f33d8..6e45b3ee 100644 --- a/lib/play/game_page.dart +++ b/lib/play/game_page.dart @@ -10,10 +10,13 @@ import 'package:wqhub/confirm_dialog.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/rules.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_counting_bar.dart'; import 'package:wqhub/play/game_navigation_bar.dart'; import 'package:wqhub/play/gameplay_bar.dart'; import 'package:wqhub/play/gameplay_menu.dart'; @@ -28,8 +31,28 @@ 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/wq/grid.dart'; +import 'package:wqhub/wq/region.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 +94,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 @@ -96,6 +123,9 @@ class _GamePageState extends State { late AnnotatedGameTree _gameTree; var _turn = wq.Color.black; var _state = GameState.playing; + var _ownership = List>.empty(); + var _acceptedDeadStones = false; + var _opponentAcceptedDeadStones = false; // End state IMapOfSets? _territoryAnnotations; @@ -112,17 +142,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 @@ -231,6 +275,21 @@ class _GamePageState extends State { }, ); + final acceptStatus = [ + ( + widget.game.white.value.username, + widget.game.myColor == wq.Color.white + ? _acceptedDeadStones + : _opponentAcceptedDeadStones + ), + ( + widget.game.black.value.username, + widget.game.myColor == wq.Color.black + ? _acceptedDeadStones + : _opponentAcceptedDeadStones + ), + ]; + return Scaffold( body: wideLayout ? Row( @@ -250,28 +309,36 @@ class _GamePageState extends State { whitePlayerCard, blackPlayerCard, SizedBox(height: 8), - if (_state == GameState.over) - GameNavigationBar( - gameTree: _gameTree, - onMovesSkipped: (n) { - setState(() { - _turn = (_gameTree.curNode.move?.col ?? - wq.Color.white) - .opposite; - }); - }, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - ), - if (_state != GameState.over) - GameplayBar( - features: widget.serverFeatures, - onPass: onPassClicked, - onManualCounting: onManualCountingClicked, - onAutomaticCounting: onAutomaticCountingClicked, - onAIReferee: onAIRefereeClicked, - onForceCounting: onForceCountingClicked, - onResign: onResignClicked, - ) + switch (_state) { + GameState.playing || + GameState.acking || + GameState.agreeToCounting => + GameplayBar( + features: widget.serverFeatures, + onPass: onPassClicked, + onAutomaticCounting: onAutomaticCountingClicked, + onAIReferee: onAIRefereeClicked, + onForceCounting: onForceCountingClicked, + onResign: onResignClicked, + ), + GameState.counting => GameCountingBar( + onAcceptDeadStones: + _acceptedDeadStones ? null : onAcceptDeadStones, + onRejectDeadStones: onRejectDeadStones, + acceptStatus: acceptStatus, + ), + GameState.over => GameNavigationBar( + gameTree: _gameTree, + onMovesSkipped: (n) { + setState(() { + _turn = (_gameTree.curNode.move?.col ?? + wq.Color.white) + .opposite; + }); + }, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + ), + }, ], ), ), @@ -288,6 +355,38 @@ class _GamePageState extends State { widget.game.myColor == wq.Color.black ? blackPlayerCard : whitePlayerCard, + if (_state == GameState.counting) ...[ + Divider(), + Text( + loc.pleaseMarkDeadStones, + textAlign: TextAlign.center, + ), + SizedBox(height: 8.0), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + spacing: 4.0, + children: [ + if (!_acceptedDeadStones) + Expanded( + child: ElevatedButton.icon( + icon: Icon(Icons.check_circle), + label: Text(loc.acceptDeadStones), + onPressed: onAcceptDeadStones, + ), + ), + Expanded( + child: ElevatedButton.icon( + icon: Icon(Icons.cancel), + label: Text(loc.rejectDeadStones), + onPressed: onRejectDeadStones, + ), + ), + ], + ), + ), + SizedBox(height: 8.0), + ], Stack( children: [ if (_state == GameState.over) @@ -302,20 +401,20 @@ class _GamePageState extends State { }, mainAxisAlignment: MainAxisAlignment.center, ), - GameplayMenu( - features: widget.serverFeatures, - rules: widget.game.rules, - handicap: widget.game.handicap, - komi: widget.game.komi, - isGameOver: _state == GameState.over, - onPass: onPassClicked, - onManualCounting: onManualCountingClicked, - onAutomaticCounting: onAutomaticCountingClicked, - onAIReferee: onAIRefereeClicked, - onForceCounting: onForceCountingClicked, - onResign: onResignClicked, - onLeave: onLeaveClicked, - ), + if (_state != GameState.counting) + GameplayMenu( + features: widget.serverFeatures, + rules: widget.game.rules, + handicap: widget.game.handicap, + komi: widget.game.komi, + isGameOver: _state == GameState.over, + onPass: onPassClicked, + onAutomaticCounting: onAutomaticCountingClicked, + onAIReferee: onAIRefereeClicked, + onForceCounting: onForceCountingClicked, + onResign: onResignClicked, + onLeave: onLeaveClicked, + ), ], ), ], @@ -324,18 +423,34 @@ class _GamePageState extends State { floatingActionButton: (wideLayout && _state == GameState.over) ? FloatingActionButton.large( onPressed: onLeaveClicked, - tooltip: 'Leave game', + tooltip: loc.leave, child: Icon(Icons.logout), ) : null, ); } + void onAcceptDeadStones() { + setState(() { + _acceptedDeadStones = true; + }); + widget.game.acceptCountingResult(true); + } + + void onRejectDeadStones() { + widget.game.acceptCountingResult(false); + } + void onPointClicked(wq.Point p) { // A move can only processed in one of the following cases: // - game is ongoing and it's our turn + // - game is in manual counting state where we can toggle groups' status // - game is over - if (!((_state == GameState.playing && _turn == widget.game.myColor) || + if (_state == GameState.counting) { + onGroupStatusToggled(p); + return; + } else if (!((_state == GameState.playing && + _turn == widget.game.myColor) || _state == GameState.over)) { return; } @@ -347,9 +462,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 +501,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 +509,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 +535,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 +548,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 +556,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 +568,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,30 +585,30 @@ 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(); + }, + ); + }, ); } - void onManualCountingClicked() { - // TODO implement - } - 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 +622,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 +651,26 @@ 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; + _acceptedDeadStones = false; + _opponentAcceptedDeadStones = false; + }); + Navigator.of(context).pop(); + }, + onNo: () { + widget.game.agreeToAutomaticCounting(false); + Navigator.of(context).pop(); + }, + ); + }, ); } return; @@ -562,12 +681,15 @@ class _GamePageState extends State { // If opponent agrees, go into counting state setState(() { _state = GameState.counting; + _acceptedDeadStones = false; + _opponentAcceptedDeadStones = false; }); } 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,78 +707,78 @@ 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, )); } setState(() { _state = GameState.playing; + _acceptedDeadStones = false; + _opponentAcceptedDeadStones = false; _territoryAnnotations = null; }); } else { - // Nothing to do. We should receive the game result event shortly if we also - // accepted the counting result. + _opponentAcceptedDeadStones = true; } } void onCountingResult(CountingResult res) { _log.fine( - '[${_state.name}] got counting result: winner=${res.winner} lead=${res.scoreLead}'); + '[${_state.name}] got counting result: winner=${res.winner} lead=${res.scoreLead} isFinal=${res.isFinal}'); - // Build the territory annotations, if provided. - _territoryAnnotations = IMapOfSets.empty(); - for (int i = 0; i < widget.game.boardSize; ++i) { - for (int j = 0; j < widget.game.boardSize; ++j) { - if (res.ownership[i][j] != null) { - final p = (i, j); - final col = res.ownership[i][j]; - _territoryAnnotations = _territoryAnnotations?.add(p, ( - type: AnnotationShape.territory.u21, - color: col == wq.Color.black ? Colors.black : Colors.white, - )); - } - } - } + // Build the territory annotations. + _ownership = res.ownership; + if (_ownership.isEmpty) _ownership = _defaultOwnership(); + _territoryAnnotations = _territoryAnnotationsFromOwnership(_ownership); // Show counting result confirmation bottom sheet - if (context.mounted) { - showModalBottomSheet( - context: context, - isDismissible: false, - routeSettings: RouteSettings(name: _countingResultBottomSheetName), - builder: (context) { - return CountingResultBottomSheet( - scoreLead: res.scoreLead, - winner: res.winner, - onAccept: () { - widget.game.acceptCountingResult(true); - maybeDismissRoute(_countingResultBottomSheetName); - }, - onReject: () { - widget.game.acceptCountingResult(false); - maybeDismissRoute(_countingResultBottomSheetName); - setState(() { - _state = GameState.playing; - _territoryAnnotations = null; - }); - }, - ); - }); + if (res.isFinal) { + if (context.mounted) { + showModalBottomSheet( + context: context, + isDismissible: false, + routeSettings: RouteSettings(name: _countingResultBottomSheetName), + builder: (context) { + return CountingResultBottomSheet( + scoreLead: res.scoreLead, + winner: res.winner, + onAccept: () { + widget.game.acceptCountingResult(true); + maybeDismissRoute(_countingResultBottomSheetName); + }, + onReject: () { + widget.game.acceptCountingResult(false); + maybeDismissRoute(_countingResultBottomSheetName); + setState(() { + _state = GameState.playing; + _territoryAnnotations = null; + }); + }, + ); + }); + } } setState(() { + // Clear acceptance status if we just got into counting state + if (_state != GameState.counting) { + _acceptedDeadStones = false; + _opponentAcceptedDeadStones = false; + } _state = GameState.counting; }); } 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 +787,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 +804,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 +819,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 +846,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(); }, @@ -819,6 +943,122 @@ class _GamePageState extends State { }, ); } + + void onGroupStatusToggled(wq.Point p) { + final col = _gameTree.stones.get(p); + if (col == null) return; + + final stones = _collectGroup(_gameTree.stones, p); + final (r, c) = p; + final removed = _ownership[r][c] == col; + for (final (i, j) in stones) { + _ownership[i][j] = _ownership[i][j]?.opposite; + } + _updateEmptyRegionOwnership(_ownership); + setState(() { + _territoryAnnotations = _territoryAnnotationsFromOwnership(_ownership); + _acceptedDeadStones = false; + }); + widget.game.toggleManuallyRemovedStones(stones.toList(), removed); + } + + List> _defaultOwnership() { + final ownership = generate2D( + widget.game.boardSize, (i, j) => _gameTree.stones.get((i, j))); + _updateEmptyRegionOwnership(ownership); + return ownership; + } + + void _updateEmptyRegionOwnership(List> ownership) { + // Clear empty regions' ownership. + for (int i = 0; i < ownership.length; ++i) { + for (int j = 0; j < ownership[i].length; ++j) { + final col = _gameTree.stones.get((i, j)); + if (col == null) ownership[i][j] = null; + } + } + // Recalculate empty regions' ownership according to current group ownership. + var allPoints = const ISet.empty(); + for (int i = 0; i < ownership.length; ++i) { + for (int j = 0; j < ownership[i].length; ++j) { + if (ownership[i][j] != null || allPoints.contains((i, j))) continue; + + var points = const ISet.empty(); + var bordersColors = const ISet.empty(); + visitRegion( + (i, j), + shouldVisit: (p) { + final (r, c) = p; + if (r < 0 || + r >= widget.game.boardSize || + c < 0 || + c >= widget.game.boardSize || + points.contains(p)) return false; + final col = _gameTree.stones.get(p); + final isEmpty = ownership[r][c] == null; + final isDead = ownership[r][c] != col; + if (col != null && !isDead) bordersColors = bordersColors.add(col); + return isEmpty || isDead; + }, + visit: (p) => points = points.add(p), + ); + + if (bordersColors.length == 1) { + for (final (r, c) in points) ownership[r][c] = bordersColors.first; + } + + allPoints = allPoints.addAll(points); + } + } + } + + static ISet _collectGroup( + IMap stones, wq.Point p) { + final groupCol = stones.get(p); + var points = const ISet.empty(); + if (groupCol == null) return points; + + visitRegion( + p, + shouldVisit: (p) => stones.get(p) == groupCol && !points.contains(p), + visit: (p) => points = points.add(p), + ); + + return points; + } + + IMapOfSets _territoryAnnotationsFromOwnership( + List> ownership) { + var annotations = IMapOfSets(); + for (int i = 0; i < ownership.length; ++i) { + for (int j = 0; j < ownership[i].length; ++j) { + if (ownership[i][j] != null) { + final col = ownership[i][j]!; + final p = (i, j); + final annotationCol = switch (col) { + wq.Color.black => Colors.black, + wq.Color.white => Colors.white, + }; + switch (widget.game.rules) { + case Rules.chinese: // Area + annotations = annotations.add(p, ( + type: AnnotationShape.territory.u21, + color: annotationCol, + )); + case Rules.japanese: // Territory + case Rules.korean: + if (col != _gameTree.stones.get(p)) { + annotations = annotations.add(p, ( + type: AnnotationShape.territory.u21, + color: annotationCol, + )); + } + } + } + } + } + return annotations; + } } class _GameInfoCard extends StatelessWidget { @@ -828,28 +1068,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..01d480f5 100644 --- a/lib/play/gameplay_bar.dart +++ b/lib/play/gameplay_bar.dart @@ -1,10 +1,10 @@ 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; final Function() onPass; - final Function() onManualCounting; final Function() onAutomaticCounting; final Function() onAIReferee; final Function() onForceCounting; @@ -14,7 +14,6 @@ class GameplayBar extends StatelessWidget { super.key, required this.features, required this.onPass, - required this.onManualCounting, required this.onAutomaticCounting, required this.onAIReferee, required this.onForceCounting, @@ -23,6 +22,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 +36,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 +57,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 +77,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..98802072 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; @@ -9,7 +10,6 @@ class GameplayMenu extends StatelessWidget { final double komi; final bool isGameOver; final Function() onPass; - final Function() onManualCounting; final Function() onAutomaticCounting; final Function() onAIReferee; final Function() onForceCounting; @@ -24,7 +24,6 @@ class GameplayMenu extends StatelessWidget { required this.komi, required this.isGameOver, required this.onPass, - required this.onManualCounting, required this.onAutomaticCounting, required this.onAIReferee, required this.onForceCounting, @@ -34,58 +33,53 @@ 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'), - ), - if (!isGameOver && features.manualCounting) - MenuItemButton( - leadingIcon: Icon(Icons.calculate), - onPressed: onManualCounting, - child: const Text('Request Counting'), + child: Text(loc.pass), ), if (!isGameOver && features.automaticCounting) 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..b297bd05 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,22 +45,26 @@ 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) { if (snapshot.hasData) { final items = snapshot.data!; + final first = items.firstOrNull; return ListView.builder( itemCount: items.length, - prototypeItem: _GameListTile( - summary: items.first, - won: true, - onTap: () {}, - onDownload: () {}, - onAISensei: () {}, - ), + prototypeItem: first == null + ? null + : _GameListTile( + summary: first, + won: true, + onTap: () {}, + onDownload: () {}, + onAISensei: () {}, + ), itemBuilder: (context, index) { final username = widget.gameClient.userInfo.value?.username ?? ''; @@ -67,8 +83,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 +92,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 +113,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 +123,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')) { + // see https://github.com/fluttercommunity/plus_plugins/issues/3645#issuecomment-3360156193 + sharePositionOrigin = Rect.fromLTWH(0, 0, 1, 1); + } } - 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 +182,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 +203,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 +234,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 +244,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 +297,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 +322,7 @@ class _GameLoadingDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return AlertDialog( title: Text(title), content: Column( @@ -323,7 +335,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..89aab42c 100644 --- a/lib/play/server_lobby_page.dart +++ b/lib/play/server_lobby_page.dart @@ -1,17 +1,26 @@ 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/automatch_preset_list_tile.dart'; import 'package:wqhub/play/game_page.dart'; 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 +29,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 +59,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) { @@ -81,30 +89,15 @@ class _ServerLobbyPageState extends WindowClassAwareState { final automatchPresetList = ListView( children: [ for (final preset in widget.gameClient.automatchPresets) - ListTile( - leading: Text('${preset.boardSize}x${preset.boardSize}'), - title: Text( - '${preset.timeControl.mainTime.inMinutes}m ${preset.timeControl.periodCount}x${preset.timeControl.timePerPeriod.inSeconds}s'), - subtitle: Text('Rules: ${preset.rules.toString()}'), - trailing: (preset.playerCount != null) - ? Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Icon(Icons.people), - Text(preset.playerCount.toString()), - ], - ) - : null, + AutomatchPresetListTile( + preset: preset, 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 +107,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 +115,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 +126,7 @@ class _ServerLobbyPageState extends WindowClassAwareState { streakCard, promotionRequirementCard, Divider(), - const Text('Auto-Match'), + Text(loc.autoMatch), Expanded(child: automatchPresetList), ], ) @@ -152,11 +145,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 +159,7 @@ class _ServerLobbyPageState extends WindowClassAwareState { child: Column( children: [ Text( - 'Auto-Match', + loc.autoMatch, style: TextTheme.of(context).titleLarge, ), Expanded(child: automatchPresetList), @@ -176,15 +169,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..3eaee3e1 --- /dev/null +++ b/lib/routes.dart @@ -0,0 +1,198 @@ +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/task_pattern_search_page.dart'; +import 'package:wqhub/train/task_pattern_search_results_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(), + TaskPatternSearchPage.routeName: (context) => TaskPatternSearchPage(), +}; + +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, onHideTask: args.onHideTask)); + 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, + )); + case TaskPatternSearchResultsPage.routeName: + final args = settings.arguments as TaskPatternSearchResultsRouteArguments; + return _mpr( + settings, + TaskPatternSearchResultsPage( + rankRange: args.rankRange, + taskTypes: args.taskTypes, + stones: args.stones, + empty: args.empty, + ), + ); + } + 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..9a1d955e 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) { @@ -62,6 +87,39 @@ class _BehaviourSettingsPageState extends State { }, ), ), + ListTile( + title: Text(loc.randomizeTaskOrientation), + subtitle: Text(loc.randomizeTaskOrientationDesc), + trailing: Switch( + value: context.settings.randomizeTaskOrientation, + onChanged: (value) { + context.settings.randomizeTaskOrientation = value; + setState(() {}); + }, + ), + ), + ListTile( + title: Text(loc.showMoveErrorsAsCrosses), + subtitle: Text(loc.showMoveErrorsAsCrossesDesc), + trailing: Switch( + value: context.settings.showMoveErrorsAsCrosses, + onChanged: (value) { + context.settings.showMoveErrorsAsCrosses = value; + setState(() {}); + }, + ), + ), + ListTile( + title: Text(loc.timeFrenzyMistakes), + subtitle: Text(loc.timeFrenzyMistakesDesc), + trailing: Switch( + value: context.settings.trackTimeFrenzyMistakes, + onChanged: (value) { + context.settings.trackTimeFrenzyMistakes = value; + setState(() {}); + }, + ), + ), ], ), ); diff --git a/lib/settings/language_page.dart b/lib/settings/language_page.dart new file mode 100644 index 00000000..7b86ae3a --- /dev/null +++ b/lib/settings/language_page.dart @@ -0,0 +1,48 @@ +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 = { + 'de': 'Deutsch', + 'en': 'English', + 'es': 'Español', + 'it': 'Italiano', + 'ro': 'Română', + 'ru': 'Русский', + 'zh': '中文 – 简体', + }; + @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..224414ce 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]; @@ -67,9 +84,54 @@ class Settings { set alwaysBlackToPlay(bool val) => prefs.setBool('$_behaviourKeyPrefix.always_black_to_play', val); + bool get randomizeTaskOrientation => + prefs.getBool('$_behaviourKeyPrefix.randomize_task_orientation') ?? false; + + set randomizeTaskOrientation(bool val) => + prefs.setBool('$_behaviourKeyPrefix.randomize_task_orientation', val); + + bool get showMoveErrorsAsCrosses => + prefs.getBool('$_behaviourKeyPrefix.show_move_errors_as_crosses') ?? + false; + + set showMoveErrorsAsCrosses(bool val) => + prefs.setBool('$_behaviourKeyPrefix.show_move_errors_as_crosses', val); + + bool get trackTimeFrenzyMistakes => + prefs.getBool('$_behaviourKeyPrefix.track_time_frenzy_mistakes') ?? false; + + set trackTimeFrenzyMistakes(bool val) => + prefs.setBool('$_behaviourKeyPrefix.track_time_frenzy_mistakes', 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 +147,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..34523af9 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'; @@ -38,6 +44,100 @@ class TaskStatEntry { other.correctCount == correctCount && other.wrongCount == wrongCount; } + + TaskStatEntry.ofTask(Task task) + : rank = task.rank, + type = task.type, + id = task.id, + correctCount = 0, + wrongCount = 0; +} + +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 @@ -114,16 +214,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 +267,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 +294,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 +322,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('now', 'localtime'), 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 +336,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('now', 'localtime'), 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 +361,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 +409,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 +449,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 +541,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.toLocal())]); + 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/symmetry.dart b/lib/symmetry.dart new file mode 100644 index 00000000..39066fbe --- /dev/null +++ b/lib/symmetry.dart @@ -0,0 +1,28 @@ +import 'package:wqhub/wq/wq.dart' as wq; + +enum Symmetry { + identity, + rotate1, + rotate2, + rotate3, + mirror1, + mirror2, + diagonal1, + diagonal2; + + wq.Point transformPoint(wq.Point p, int boardSize) { + final (r, c) = p; + final maxCoord = boardSize - 1; + + return switch (this) { + Symmetry.identity => p, + Symmetry.rotate1 => (c, maxCoord - r), + Symmetry.rotate2 => (maxCoord - r, maxCoord - c), + Symmetry.rotate3 => (maxCoord - c, r), + Symmetry.mirror1 => (maxCoord - r, c), + Symmetry.mirror2 => (r, maxCoord - c), + Symmetry.diagonal1 => (c, r), + Symmetry.diagonal2 => (maxCoord - c, maxCoord - r), + }; + } +} 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..4b87f76b 100644 --- a/lib/train/collection_page.dart +++ b/lib/train/collection_page.dart @@ -1,29 +1,35 @@ 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_board.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/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 +45,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,54 +65,23 @@ 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); - 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 boardArea = TaskBoard( + task: currentTask, + turn: turn, + stones: gameTree.stones, + annotations: continuationAnnotations ?? gameTree.annotations, + dismissable: solveStatus != null, + onPointClicked: (p) => onMove(p, wideLayout), + onDismissed: onNextTask, ); 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 +91,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, @@ -134,19 +102,24 @@ class _CollectionPageState extends State body: Center( child: Row( children: [ - Expanded(child: Center(child: board)), + Expanded(child: boardArea), VerticalDivider(thickness: 1, width: 8), _SideBar( taskTitle: taskTitle, 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, + onCopySgf: onCopySgf, + onResetTask: onResetTask, + onNextTask: onNextTask, onExit: _onExit, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, timeDisplay: timeDisplay, ), ], @@ -172,44 +145,67 @@ class _CollectionPageState extends State ), ], ), - body: Center( - child: board, - ), + body: boardArea, 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, + onCopySgf: onCopySgf, + 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 +214,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 +242,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 +287,16 @@ 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()? onCopySgf; + 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 +305,16 @@ 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, + this.onCopySgf, + required this.onResetTask, + required this.onNextTask, required this.onExit, + required this.onPreviousMove, + required this.onNextMove, + required this.onUpdateUpsolveMode, required this.timeDisplay, }); @@ -466,10 +355,15 @@ class _SideBar extends StatelessWidget { (status == null) ? Center(child: timeDisplay) : TaskActionBar( + upsolveMode: upsolveMode, onShowSolution: onShowSolution, - onShare: onShare, - onNext: onNext, - onReplay: onReplay, + onShareTask: onShareTask, + onCopySgf: onCopySgf, + 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..50ed3d90 --- /dev/null +++ b/lib/train/custom_exam_selection_page.dart @@ -0,0 +1,277 @@ +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(debugLabel: 'custom_exam_selection_page'); + 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..dc1ff105 100644 --- a/lib/train/endgame_exam_page.dart +++ b/lib/train/endgame_exam_page.dart @@ -1,557 +1,68 @@ 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) + .map((task) => task.withRandomSymmetry( + randomize: context.settings.randomizeTaskOrientation)) + .toIList()), + 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..adc10e71 --- /dev/null +++ b/lib/train/exam_page.dart @@ -0,0 +1,494 @@ +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_board.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/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 boardArea = TaskBoard( + task: currentTask, + turn: turn, + stones: gameTree.stones, + annotations: continuationAnnotations ?? gameTree.annotations, + dismissable: solveStatus != null, + onPointClicked: (p) => onMove(p, wideLayout), + onDismissed: _onNext, + ); + + 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: boardArea), + 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, + onCopySgf: onCopySgf, + 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: boardArea, + bottomNavigationBar: BottomAppBar( + height: upsolveMode == UpsolveMode.auto ? 80.0 : 160.0, + child: (solveStatus == null) + ? Center(child: timeDisplay) + : TaskActionBar( + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onCopySgf: onCopySgf, + 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()? onCopySgf; + 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, + this.onCopySgf, + 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, + onCopySgf: onCopySgf, + 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..249d5701 100644 --- a/lib/train/grading_exam_page.dart +++ b/lib/train/grading_exam_page.dart @@ -1,557 +1,73 @@ 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) + .map((task) => task.withRandomSymmetry( + randomize: context.settings.randomizeTaskOrientation)) + .toIList()), + 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..c99f9ad5 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,20 @@ class RankRange { bool isSingle() => from == to; + bool isInside(RankRange that) => that.from <= from && to <= that.to; + bool contains(Rank rank) => from <= rank && rank <= 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..8ff8a6b7 100644 --- a/lib/train/ranked_mode_page.dart +++ b/lib/train/ranked_mode_page.dart @@ -1,27 +1,29 @@ 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_board.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/task_source/task_source.dart'; 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 +33,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,53 +51,21 @@ 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); - 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 boardArea = TaskBoard( + task: currentTask, + turn: turn, + stones: gameTree.stones, + annotations: continuationAnnotations ?? gameTree.annotations, + dismissable: solveStatus != null, + onPointClicked: (p) => onMove(p, wideLayout), + onDismissed: onNextTask, ); 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); @@ -112,17 +75,22 @@ class _RankedModePageState extends State body: Center( child: Row( children: [ - Expanded(child: Center(child: board)), + Expanded(child: boardArea), 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, + status: solveStatus, + upsolveMode: upsolveMode, + onShowSolution: onShowContinuations, + onShareTask: onShareTask, + onCopySgf: onCopySgf, + onResetTask: onResetTask, + onNextTask: onNextTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onUpdateUpsolveMode: onUpdateUpsolveMode, timeDisplay: rankDisplay, ), ], @@ -149,151 +117,54 @@ class _RankedModePageState extends State ), ], ), - body: Center( - child: board, - ), + body: boardArea, 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, + onCopySgf: onCopySgf, + 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 +172,15 @@ 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()? onCopySgf; + final Function()? onResetTask; + final Function()? onNextTask; + final Function() onPreviousMove; + final Function() onNextMove; + final Function(UpsolveMode) onUpdateUpsolveMode; final Widget timeDisplay; const _SideBar({ @@ -312,10 +188,15 @@ 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, + this.onCopySgf, + required this.onResetTask, + required this.onNextTask, + required this.onPreviousMove, + required this.onNextMove, + required this.onUpdateUpsolveMode, required this.timeDisplay, }); @@ -360,10 +241,15 @@ class _SideBar extends StatelessWidget { child: timeDisplay, ) : TaskActionBar( + upsolveMode: upsolveMode, onShowSolution: onShowSolution, - onShare: onShare, - onNext: onNext, - onReplay: onReplay, + onShareTask: onShareTask, + onCopySgf: onCopySgf, + 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..6d04c571 100644 --- a/lib/train/single_task_page.dart +++ b/lib/train/single_task_page.dart @@ -1,50 +1,47 @@ 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; + final VoidCallback? onHideTask; + + const SingleTaskRouteArguments({required this.task, this.onHideTask}); +} + class SingleTaskPage extends StatefulWidget { - const SingleTaskPage({super.key, required this.task}); + static const routeName = '/task'; + + const SingleTaskPage({ + super.key, + required this.task, + this.onHideTask, + }); final Task task; + final VoidCallback? onHideTask; @override State createState() => _SingleTaskPageState(); } 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 +78,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 +100,15 @@ 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, + onCopySgf: onCopySgf, + onResetTask: onResetTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onHideTask: widget.onHideTask, + onUpdateUpsolveMode: onUpdateUpsolveMode, timeDisplay: Text(widget.task.deepLink()), ), ], @@ -128,143 +130,56 @@ 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, + onCopySgf: onCopySgf, + onResetTask: onResetTask, + onPreviousMove: onPreviousMove, + onNextMove: onNextMove, + onHideTask: widget.onHideTask, + 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()? onCopySgf; + final Function()? onResetTask; + final Function()? onHideTask; + 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, + this.onCopySgf, + required this.onResetTask, + this.onHideTask, + required this.onPreviousMove, + required this.onNextMove, + required this.onUpdateUpsolveMode, required this.timeDisplay, }); @@ -297,15 +212,17 @@ 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, + onCopySgf: onCopySgf, + onResetTask: onResetTask, + onHideTask: onHideTask, + 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..7f10cd68 100644 --- a/lib/train/tag_exam_page.dart +++ b/lib/train/tag_exam_page.dart @@ -1,544 +1,72 @@ -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:fast_immutable_collections/fast_immutable_collections.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(); - } +class TagExamPage extends StatelessWidget { + static const routeName = '/train/tag_exam'; - @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 - .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(); - }); - } + static const taskCount = 10; + static const timePerTask = const Duration(minutes: 5); + static const maxMistakes = 2; - 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) + .map((task) => task.withRandomSymmetry( + randomize: context.settings.randomizeTaskOrientation)) + .toIList()), + 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..d5c3c5db 100644 --- a/lib/train/task_action_bar.dart +++ b/lib/train/task_action_bar.dart @@ -1,48 +1,169 @@ 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()? onCopySgf; + final Function()? onHideTask; + 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.onCopySgf, + this.onHideTask, + 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: onCopySgf, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + Icon(Icons.copy), + Text(loc.copySGF), + ], + ), + ), + 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), + ], + }, + ), + ), + if (onHideTask != null) + PopupMenuItem( + onTap: onHideTask, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + Icon(Icons.visibility_off), + Text(loc.hideTask), + ], + ), + ), + ], + ), + ); + 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_board.dart b/lib/train/task_board.dart new file mode 100644 index 00000000..9454f507 --- /dev/null +++ b/lib/train/task_board.dart @@ -0,0 +1,86 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +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/settings/shared_preferences_inherited_widget.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/wq/wq.dart' as wq; + +class TaskBoard extends StatelessWidget { + final Task task; + final wq.Color turn; + final IMap stones; + final IMapOfSets annotations; + final bool dismissable; + final Function(wq.Point) onPointClicked; + final Function() onDismissed; + + const TaskBoard({ + super.key, + required this.task, + required this.turn, + required this.stones, + required this.annotations, + required this.dismissable, + required this.onPointClicked, + required this.onDismissed, + }); + + @override + Widget build(BuildContext context) { + 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: task.boardSize, + subBoard: SubBoard( + topLeft: task.topLeft, + size: 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: onPointClicked, + turn: turn, + stones: stones, + annotations: annotations, + confirmTap: context.settings.confirmMoves, + ); + }, + ); + + return Dismissible( + key: ValueKey(task.id), + resizeDuration: null, + direction: + dismissable ? DismissDirection.endToStart : DismissDirection.none, + onDismissed: (_) => onDismissed(), + child: Center(child: board), + ); + } +} diff --git a/lib/train/task_pattern_search_page.dart b/lib/train/task_pattern_search_page.dart new file mode 100644 index 00000000..d991321a --- /dev/null +++ b/lib/train/task_pattern_search_page.dart @@ -0,0 +1,238 @@ +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:wqhub/board/board.dart'; +import 'package:wqhub/board/board_annotation.dart'; +import 'package:wqhub/board/board_settings.dart'; +import 'package:wqhub/input/rank_range_form_field.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/settings/shared_preferences_inherited_widget.dart'; +import 'package:wqhub/train/rank_range.dart'; +import 'package:wqhub/train/task_pattern_search_results_page.dart'; +import 'package:wqhub/train/task_type.dart'; +import 'package:wqhub/wq/rank.dart'; +import 'package:wqhub/wq/wq.dart' as wq; + +class TaskPatternSearchPage extends StatefulWidget { + static const routeName = '/train/task_pattern_search'; + + @override + State createState() => _TaskPatternSearchPageState(); +} + +class _TaskPatternSearchPageState extends State { + final _formKey = GlobalKey(debugLabel: 'task_pattern_search_page'); + var _boardSize = 13; + wq.Color? _turn = wq.Color.black; + var _stones = const IMap.empty(); + var _empty = const ISet.empty(); + var _rankRange = RankRange(from: Rank.k15, to: Rank.d7); + var _selectedTaskTypes = ISet(TaskType.values); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final boardSettings = BoardSettings( + size: _boardSize, + theme: context.settings.boardTheme, + edgeLine: context.settings.edgeLine, + stoneShadows: context.settings.stoneShadows, + ); + final annotations = IMapOfSets.from(IMap({ + for (final p in _empty) + p: ISet({ + ( + type: AnnotationShape.circle.u21, + color: Colors.blueAccent, + ) + }) + })); + final board = LayoutBuilder( + builder: (context, constraints) { + final boardSize = constraints.biggest.shortestSide - + 2 * (boardSettings.border?.size ?? 0); + return Board( + size: boardSize, + settings: boardSettings, + onPointClicked: _onPointClicked, + turn: _turn, + stones: _stones, + annotations: annotations, + confirmTap: context.settings.confirmMoves, + ); + }, + ); + final boardSizeSegmentedButton = SegmentedButton( + segments: >[ + ButtonSegment( + value: 9, + label: Text(loc.nxnBoardSize(9)), + ), + ButtonSegment( + value: 13, + label: Text(loc.nxnBoardSize(13)), + ), + ButtonSegment( + value: 19, + label: Text(loc.nxnBoardSize(19)), + ), + ], + selected: {_boardSize}, + onSelectionChanged: (Set newSelection) { + setState(() { + _boardSize = newSelection.first; + _stones = const IMap.empty(); + _empty = const ISet.empty(); + }); + }, + ); + final turnSegmentedButton = SegmentedButton( + segments: >[ + ButtonSegment( + value: wq.Color.black, + label: Text(loc.black), + ), + ButtonSegment( + value: wq.Color.white, + label: Text(loc.white), + ), + ButtonSegment( + value: null, + label: Text(loc.empty), + ), + ], + selected: {_turn}, + onSelectionChanged: (Set newSelection) { + setState(() { + _turn = newSelection.first; + }); + }, + ); + return Scaffold( + appBar: AppBar( + title: Text(loc.findTask), + actions: [ + TextButton.icon( + icon: const Icon(Icons.clear), + label: Text(loc.clearBoard), + onPressed: () { + setState(() { + _stones = const IMap.empty(); + _empty = const ISet.empty(); + }); + }, + ), + ], + ), + 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: [ + boardSizeSegmentedButton, + turnSegmentedButton, + board, + 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; + }); + }, + ), + 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 = + _selectedTaskTypes.add(taskType); + else + _selectedTaskTypes = + _selectedTaskTypes.remove(taskType); + }); + }, + ) + ], + ), + ], + ), + ), + ), + ), + ), + ), + bottomNavigationBar: BottomAppBar( + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 400), + child: Row( + children: [ + Expanded( + child: FilledButton( + child: Text(loc.find), + onPressed: () { + if (_formKey.currentState!.validate()) { + Navigator.of(context).pushNamed( + TaskPatternSearchResultsPage.routeName, + arguments: TaskPatternSearchResultsRouteArguments( + rankRange: _rankRange, + taskTypes: _selectedTaskTypes, + stones: _stones, + empty: _empty, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Invalid task search settings. Please fix the errors.'), + showCloseIcon: true, + ), + ); + } + }, + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _onPointClicked(wq.Point p) { + setState(() { + if (_turn == null) { + _stones = _stones.remove(p); + _empty = _empty.toggle(p); + } else { + _empty = _empty.remove(p); + if (_stones.contains(p, _turn!)) + _stones = _stones.remove(p); + else + _stones = _stones.add(p, _turn!); + } + }); + } +} diff --git a/lib/train/task_pattern_search_results_page.dart b/lib/train/task_pattern_search_results_page.dart new file mode 100644 index 00000000..5edb398c --- /dev/null +++ b/lib/train/task_pattern_search_results_page.dart @@ -0,0 +1,122 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:wqhub/cancellable_isolate_stream.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/stats/stats_db.dart'; +import 'package:wqhub/train/rank_range.dart'; +import 'package:wqhub/train/task_preview_tile.dart'; +import 'package:wqhub/train/task_repository.dart'; +import 'package:wqhub/train/task_type.dart'; +import 'package:wqhub/window_class_aware_state.dart'; +import 'package:wqhub/wq/wq.dart' as wq; + +class TaskPatternSearchResultsRouteArguments { + final RankRange rankRange; + final ISet taskTypes; + final IMap stones; + final ISet empty; + + TaskPatternSearchResultsRouteArguments( + {required this.rankRange, + required this.taskTypes, + required this.stones, + required this.empty}); +} + +class TaskPatternSearchResultsPage extends StatefulWidget { + static const routeName = '/train/task_pattern_search_results'; + + final RankRange rankRange; + final ISet taskTypes; + final IMap stones; + final ISet empty; + + const TaskPatternSearchResultsPage({ + super.key, + required this.rankRange, + required this.taskTypes, + required this.stones, + required this.empty, + }); + + @override + State createState() => + _TaskPatternSearchResultsPageState(); +} + +class _TaskPatternSearchResultsPageState + extends WindowClassAwareState { + late final CancellableIsolateStream _results; + final _tasks = []; + final _taskAdded = {}; + var _searchComplete = false; + + @override + void initState() { + super.initState(); + _results = findTasks( + widget.rankRange, widget.taskTypes, widget.stones, widget.empty); + } + + @override + void dispose() { + _searchComplete = true; + _results.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _results.stream, + builder: (context, snapshot) { + final loc = AppLocalizations.of(context)!; + if (snapshot.connectionState == ConnectionState.done) { + _searchComplete = true; + } + if (snapshot.hasData) { + Task t = snapshot.data!; + if (!_taskAdded.contains(t.id)) { + _taskAdded.add(t.id); + _tasks.add(t); + if (_tasks.length >= 100) _results.cancel(); + } + } + return Scaffold( + appBar: AppBar( + title: Text( + _searchComplete ? loc.findTaskResults : loc.findTaskSearching), + actions: [ + if (!_searchComplete) + Padding( + padding: const EdgeInsets.all(8.0), + child: CircularProgressIndicator(), + ), + ], + ), + body: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 800), + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: switch (windowClass) { + WindowClass.compact => 3, + WindowClass.medium => 6, + WindowClass.expanded => 6, + WindowClass.large => 6, + WindowClass.extraLarge => 6, + }, + ), + itemCount: _tasks.length, + itemBuilder: (context, index) => TaskPreviewTile( + task: TaskStatEntry.ofTask(_tasks[index]), + showSolveRatio: false, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/train/task_preview_tile.dart b/lib/train/task_preview_tile.dart index 44748661..1087902a 100644 --- a/lib/train/task_preview_tile.dart +++ b/lib/train/task_preview_tile.dart @@ -2,6 +2,7 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:wqhub/board/board.dart'; import 'package:wqhub/board/board_settings.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/single_task_page.dart'; @@ -11,8 +12,13 @@ import 'package:wqhub/wq/wq.dart' as wq; class TaskPreviewTile extends StatefulWidget { final TaskStatEntry task; final Function()? onHideTask; + final bool showSolveRatio; - const TaskPreviewTile({super.key, required this.task, this.onHideTask}); + const TaskPreviewTile( + {super.key, + required this.task, + this.onHideTask, + this.showSolveRatio = true}); @override State createState() => _TaskPreviewTileState(); @@ -38,6 +44,7 @@ class _TaskPreviewTileState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; return FutureBuilder( future: taskFut, builder: (context, snapshot) { @@ -55,6 +62,7 @@ class _TaskPreviewTileState extends State { theme: context.settings.boardTheme, edgeLine: context.settings.edgeLine, stoneShadows: context.settings.stoneShadows, + interactive: false, ); var stones = const IMap.empty(); @@ -85,10 +93,18 @@ 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, + onHideTask: widget.onHideTask != null + ? () { + widget.onHideTask!(); + Navigator.of(context).pop(); + } + : null, + ), ); }, onLongPressStart: (details) { @@ -102,7 +118,7 @@ class _TaskPreviewTileState extends State { menuChildren: [ MenuItemButton( onPressed: () => widget.onHideTask?.call(), - child: const Text('Hide task'), + child: Text(loc.hideTask), ), ], child: Center( @@ -115,9 +131,10 @@ class _TaskPreviewTileState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Text(widget.task.rank.toString()), - Text( - '${widget.task.correctCount} / ${widget.task.correctCount + widget.task.wrongCount}', - style: TextTheme.of(context).labelSmall), + if (widget.showSolveRatio) + Text( + '${widget.task.correctCount} / ${widget.task.correctCount + widget.task.wrongCount}', + style: TextTheme.of(context).labelSmall), ], ) ], diff --git a/lib/train/task_repository.dart b/lib/train/task_repository.dart index c88a10b3..fffcba08 100644 --- a/lib/train/task_repository.dart +++ b/lib/train/task_repository.dart @@ -1,10 +1,20 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:math'; + import 'package:animated_tree_view/tree_view/tree_node.dart'; +import 'package:crypto/crypto.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; +import 'package:wqhub/cancellable_isolate_stream.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/symmetry.dart'; import 'package:wqhub/train/task_tag.dart'; import 'package:wqhub/train/task_type.dart'; import 'package:wqhub/train/variation_tree.dart'; @@ -20,6 +30,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); @@ -75,6 +98,62 @@ class Task { ), }; + Task withSymmetry(Symmetry symmetry) { + if (symmetry == Symmetry.identity) { + return this; + } + + final transformedStonesMap = >{}; + for (final entry in initialStones.entries) { + final transformedPoints = entry.value.fold>( + const ISet.empty(), + (accPoints, point) => + accPoints.add(symmetry.transformPoint(point, boardSize)), + ); + transformedStonesMap[entry.key] = transformedPoints; + } + + return Task( + id: id, + rank: rank, + type: type, + first: first, + boardSize: boardSize, + subBoardSize: subBoardSize, + topLeft: _transformTopLeft(topLeft, boardSize, subBoardSize, symmetry), + initialStones: IMapOfSets(transformedStonesMap), + variationTree: variationTree.withSymmetry(symmetry, boardSize), + ); + } + + static wq.Point _transformTopLeft( + wq.Point topLeft, int boardSize, int subBoardSize, Symmetry symmetry) { + if (symmetry == Symmetry.identity) return topLeft; + + final (x1, y1) = topLeft; + final (x2, y2) = (x1 + subBoardSize - 1, y1 + subBoardSize - 1); + + final topRight = (x2, y1); + final bottomRight = (x2, y2); + final bottomLeft = (x1, y2); + + final tlS = symmetry.transformPoint(topLeft, boardSize); + final trS = symmetry.transformPoint(topRight, boardSize); + final blS = symmetry.transformPoint(bottomRight, boardSize); + final brS = symmetry.transformPoint(bottomLeft, boardSize); + + final topLeftS = ( + [tlS.$1, trS.$1, blS.$1, brS.$1].reduce(min), + [tlS.$2, trS.$2, blS.$2, brS.$2].reduce(min), + ); + + return topLeftS; + } + + Task withRandomSymmetry({required bool randomize}) => randomize + ? withSymmetry(Symmetry.values[Random().nextInt(Symmetry.values.length)]) + : this; + String deepLink() { final rs = rank.index.toRadixString(16).padLeft(2, '0'); final ts = type.index.toRadixString(16).padLeft(2, '0'); @@ -106,8 +185,9 @@ class _TaskBucket { final wq.Rank rank; final TaskType type; final ByteData data; + final ByteData patternIndexData; - _TaskBucket(this.rank, this.type, this.data); + _TaskBucket(this.rank, this.type, this.data, this.patternIndexData); int get size => data.getInt16(0); @@ -170,6 +250,36 @@ class _TaskBucket { ); } + IMap _stonesAt(int index) { + assert(0 <= index && index < size); + var offset = data.getInt32(2 + index * 8 + 4); + + // Read header info + data.getUint8(offset++); + data.getUint8(offset++); + String.fromCharCodes([data.getUint8(offset++), data.getUint8(offset++)]); + + var stones = const IMap.empty(); + + // Read initial black stones + final initialBlackStoneCount = data.getUint8(offset++); + for (int i = 0; i < initialBlackStoneCount; ++i) { + final p = String.fromCharCodes( + [data.getUint8(offset++), data.getUint8(offset++)]); + stones = stones.add(wq.parseSgfPoint(p), wq.Color.black); + } + + // Read initial white stones + final initialWhiteStoneCount = data.getUint8(offset++); + for (int i = 0; i < initialWhiteStoneCount; ++i) { + final p = String.fromCharCodes( + [data.getUint8(offset++), data.getUint8(offset++)]); + stones = stones.add(wq.parseSgfPoint(p), wq.Color.white); + } + + return stones; + } + int _idAt(int index) { assert(0 <= index && index < size); return data.getInt32(2 + index * 8); @@ -216,6 +326,64 @@ class _TaskBucket { offset = newOffset; } } + + static final int _patternHashSizeInBytes = 32; + + Stream find(IMap wantStones, + ISet wantEmpty, Set wantFeatures) async* { + for (int i = 0; i < size; ++i) { + final gotStones = _stonesAt(i); + final h = patternIndexData.buffer + .asUint8List(_patternHashSizeInBytes * i, _patternHashSizeInBytes) + .reversedView; + if (_matchFingerprint(h, wantFeatures) && + _matchStones(wantStones, wantEmpty, gotStones)) { + yield at(i); + } + } + } + + bool _matchFingerprint(List h, Set wantFeatures) { + for (final fp in wantFeatures) { + final x = h[fp >> 3]; + if ((x & (1 << (fp & 7))) == 0) return false; + } + return true; + } + + bool _matchStones( + IMap wantStones, + ISet wantEmpty, + IMap gotStones, + ) { + final ex = wantStones.entries.first; + for (final sym in Symmetry.values) { + final (rx, cx) = sym.transformPoint(ex.key, 19); + for (final ey in gotStones.entries) { + final (ry, cy) = ey.key; + // Assume ex and ey represent the same stone and check if the remaining stones match. + var ok = true; + for (final pz in wantEmpty) { + final (rz, cz) = sym.transformPoint(pz, 19); + final zz = gotStones.get((ry + (rz - rx), cy + (cz - cx))); + if (zz != null) { + ok = false; + break; + } + } + for (final ez in wantStones.entries) { + final (rz, cz) = sym.transformPoint(ez.key, 19); + final zz = gotStones.get((ry + (rz - rx), cy + (cz - cx))); + if (zz == null || ((ex.value == ey.value) != (zz == ez.value))) { + ok = false; + break; + } + } + if (ok) return true; + } + } + return false; + } } class _TagBucket { @@ -382,7 +550,9 @@ class TaskRepository { final (rank, typ) = rt; final data = await rootBundle.load('assets/tasks/${rank.index}_${typ.index}.bin'); - return (rt, _TaskBucket(rank, typ, data)); + final patternIndexData = + await rootBundle.load('assets/tasks/${rank.index}_${typ.index}.idx'); + return (rt, _TaskBucket(rank, typ, data, patternIndexData)); })); final buckets = <(wq.Rank, TaskType), _TaskBucket>{ for (final (rt, bucket) in entries) rt: bucket @@ -414,7 +584,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 +630,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; @@ -471,4 +691,133 @@ class TaskRepository { node.add(childNode); } } + + Stream _find(RankRange rankRange, ISet types, + IMap stones, ISet empty) async* { + final features = _computePatternFeatures(stones); + for (final e in _buckets.entries) { + final (rank, type) = e.key; + if (!rankRange.contains(rank) || !types.contains(type)) continue; + yield* e.value.find(stones, empty, features); + await Future.delayed( + Duration.zero); // yield control to handle cancellation + } + } + + Set _computePatternFeatures(IMap stones) { + final features = {}; + for (final ex in stones.entries) { + final (rx, cx) = ex.key; + final xCol = ex.value; + for (final ey in stones.entries) { + final (ry, cy) = ey.key; + final yCol = ey.value; + + if (ex.key == ey.key) continue; + features.add(_fingerprint(rx, cx, xCol, ry, cy, yCol)); + } + } + return features; + } + + int _fingerprint( + int rx, int cx, wq.Color xCol, int ry, int cy, wq.Color yCol) { + final lens = [(rx - ry).abs(), (cx - cy).abs()]; + lens.sort(); + final sameCol = xCol == yCol ? 1 : 0; + final digest = + sha256.convert(utf8.encode('${lens[0]}.${lens[1]}.$sameCol')); + return digest.bytes[0]; + } +} + +class _FindTaskRequest { + final TaskRepository taskRepo; + final RankRange rankRange; + final ISet taskTypes; + final IMap stones; + final ISet empty; + final SendPort sendPort; + + const _FindTaskRequest({ + required this.taskRepo, + required this.rankRange, + required this.taskTypes, + required this.stones, + required this.empty, + required this.sendPort, + }); +} + +CancellableIsolateStream findTasks( + RankRange rankRange, + ISet taskTypes, + IMap stones, + ISet empty) { + final mainRecvPort = ReceivePort(); + final ctrl = StreamController(); + SendPort? isoSendPort; + + void cancel() { + isoSendPort?.send('cancel'); + mainRecvPort.close(); + ctrl.close(); + } + + final req = _FindTaskRequest( + taskRepo: TaskRepository(), + rankRange: rankRange, + taskTypes: taskTypes, + stones: stones, + empty: empty, + sendPort: mainRecvPort.sendPort, + ); + + Isolate.spawn(_findTaskOnIsolate, req).then((isolate) { + // Nothing to do + }).catchError((err) { + ctrl.addError(err); + ctrl.close(); + }); + + mainRecvPort.listen((msg) { + if (msg is SendPort) { + isoSendPort = msg; + } else if (msg == null) { + mainRecvPort.close(); + ctrl.close(); + } else if (msg is Task) { + ctrl.add(msg); + } + }); + + return CancellableIsolateStream(stream: ctrl.stream, cancel: cancel); +} + +void _findTaskOnIsolate(_FindTaskRequest req) async { + final recvPort = ReceivePort(); + StreamSubscription? sub; + + req.sendPort.send(recvPort.sendPort); + + void cancelSearch() { + recvPort.close(); + sub?.cancel(); + } + + recvPort.listen((msg) { + if (msg == 'cancel') cancelSearch(); + }); + + sub = req.taskRepo + ._find(req.rankRange, req.taskTypes, req.stones, req.empty) + .listen((t) { + req.sendPort.send(t); + }, onError: (err) { + req.sendPort.send(null); + cancelSearch(); + }, onDone: () { + req.sendPort.send(null); + cancelSearch(); + }, cancelOnError: true); } diff --git a/lib/train/task_solving_state_mixin.dart b/lib/train/task_solving_state_mixin.dart new file mode 100644 index 00000000..924d2ee3 --- /dev/null +++ b/lib/train/task_solving_state_mixin.dart @@ -0,0 +1,249 @@ +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, + initialStones: currentTask.initialStones); + 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(); + final showErrorsAsCrosses = context.settings.showMoveErrorsAsCrosses; + for (final (p, st) + in _vtreeIt?.continuations() ?? <(wq.Point, VariationStatus)>[]) { + continuationAnnotations = continuationAnnotations?.add(p, ( + type: switch (st) { + VariationStatus.correct => AnnotationShape.dot.u21, + VariationStatus.wrong => showErrorsAsCrosses + ? AnnotationShape.cross.u21 + : 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 onCopySgf() { + final sgfData = gameTree.sgf(); + Clipboard.setData(ClipboardData(text: sgfData)).then((void _) { + if (context.mounted) { + final loc = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(loc.msgSgfCopied), + duration: Duration(seconds: 2), + ), + ); + } + }); + } + + 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..767066d7 100644 --- a/lib/train/task_source/ranked_mode_task_source.dart +++ b/lib/train/task_source/ranked_mode_task_source.dart @@ -16,12 +16,14 @@ final class RankedModeTaskSource extends TaskSource { Task _cur; double _rank; + bool randomizeLayout; - RankedModeTaskSource(double rank) + RankedModeTaskSource(double rank, bool this.randomizeLayout) : _rank = rank, _cur = TaskRepository() - .read(Rank.values[rank.toInt()], _taskTypes, 1) - .first; + .readByTypes(Rank.values[rank.toInt()], _taskTypes, 1) + .first + .withRandomSymmetry(randomize: randomizeLayout); @override bool next(prevStatus, prevSolveTime, {Function(double)? onRankChanged}) { @@ -32,8 +34,10 @@ 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 + .withRandomSymmetry(randomize: randomizeLayout); 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..9d87536f 100644 --- a/lib/train/task_source/time_frenzy_task_source.dart +++ b/lib/train/task_source/time_frenzy_task_source.dart @@ -17,9 +17,13 @@ final class TimeFrenzyTaskSource extends TaskSource { Task _cur; double _rank = Rank.k15.index.toDouble(); int _mistakeCount = 0; + bool randomizeLayout = false; - TimeFrenzyTaskSource() - : _cur = TaskRepository().read(Rank.k15, _taskTypes, 1).first; + TimeFrenzyTaskSource({required bool this.randomizeLayout}) + : _cur = TaskRepository() + .readByTypes(Rank.k15, _taskTypes, 1) + .first + .withRandomSymmetry(randomize: randomizeLayout); @override bool next(prevStatus, prevSolveTime, {Function(double)? onRankChanged}) { @@ -31,8 +35,11 @@ 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 + .withRandomSymmetry(randomize: randomizeLayout); + ; return true; } diff --git a/lib/train/task_tag.dart b/lib/train/task_tag.dart index c9222339..ee548ee8 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.d1, to: Rank.d3), RankRange(from: Rank.d4, to: Rank.d6), ]), TaskTag.josekiFundamentals => IList([ @@ -2999,7 +3006,7 @@ enum TaskTag { RankRange(from: Rank.d4, to: Rank.d5) ]), TaskTag.tripodGroupWithExtraLegAndSimilar => IList([ - RankRange(from: Rank.k10, to: Rank.d5), + RankRange(from: Rank.k10, to: Rank.k5), RankRange(from: Rank.k4, to: Rank.d1), ]), TaskTag.twoHaneGainOneLiberty => 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..07316c7a 100644 --- a/lib/train/time_frenzy_page.dart +++ b/lib/train/time_frenzy_page.dart @@ -1,23 +1,30 @@ 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/stats/stats_db.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_board.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'; import 'package:wqhub/train/task_source/task_source.dart'; 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 +36,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 +47,6 @@ class _TimeFrenzyPageState extends State @override void initState() { super.initState(); - _restartCurrentTask(); _stopwatch.start(); } @@ -55,54 +58,21 @@ class _TimeFrenzyPageState 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); - 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: _gameTree.annotations, - confirmTap: context.settings.confirmMoves, - ); - }, + final boardArea = TaskBoard( + task: currentTask, + turn: turn, + stones: gameTree.stones, + annotations: continuationAnnotations ?? gameTree.annotations, + dismissable: false, + onPointClicked: (p) => onMove(p, wideLayout), + onDismissed: () {/* Next task is automatic */}, ); 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, @@ -123,7 +93,7 @@ class _TimeFrenzyPageState extends State body: Center( child: Row( children: [ - Expanded(child: Center(child: board)), + Expanded(child: boardArea), VerticalDivider(thickness: 1, width: 8), _SideBar( taskTitle: taskTitle, @@ -153,9 +123,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), @@ -165,9 +134,7 @@ class _TimeFrenzyPageState extends State ), ], ), - body: Center( - child: board, - ), + body: boardArea, bottomNavigationBar: BottomAppBar( child: timeDisplay, ), @@ -175,41 +142,22 @@ 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; + + @override + void onSolveStatus(VariationStatus status) { + final solved = status == VariationStatus.correct; + + if (context.settings.trackTimeFrenzyMistakes) { + StatsDB().addTaskAttempt( + currentTask.rank, currentTask.type, currentTask.id, solved); } - } - void _setSolveStatus(VariationStatus status, bool wideLayout) { - notifySolveStatus(status, wideLayout); - if (status == VariationStatus.correct) { - if (context.settings.sound) AudioController().correct(); + if (solved) { _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 +168,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 +185,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 +201,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 +225,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 +261,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..ecd05ba4 100644 --- a/lib/train/variation_tree.dart +++ b/lib/train/variation_tree.dart @@ -1,16 +1,17 @@ import 'dart:math'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:wqhub/l10n/app_localizations.dart'; +import 'package:wqhub/symmetry.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, }; } @@ -27,28 +28,83 @@ class VariationTree { (cur, t) => t.finalStatus() == VariationStatus.correct ? VariationStatus.correct : cur); + + VariationTree withSymmetry(Symmetry symmetry, int boardSize) { + if (symmetry == Symmetry.identity) { + return this; + } + + final transformedChildren = + children.entries.fold>( + const IMap.empty(), + (acc, entry) { + final transformedPoint = symmetry.transformPoint(entry.key, boardSize); + final transformedChild = entry.value.withSymmetry(symmetry, boardSize); + return acc.add(transformedPoint, transformedChild); + }, + ); + + return VariationTree( + status: status, + children: transformedChildren, + ); + } } class VariationTreeIterator { - VariationTree _tree; + final _trees = []; + final _moves = []; + var _cur = 0; + + VariationTreeIterator({required tree}) { + _trees.add(tree); + } + + int get depth => _cur; - VariationTreeIterator({required tree}) : _tree = tree; + 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..c16e2c86 --- /dev/null +++ b/lib/version_patch.dart @@ -0,0 +1,2129 @@ +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); + _versionPatch_0_1_10(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); +} + +void _versionPatch_0_1_10(Settings settings) { + const version = '0.1.10'; + if (settings.getVersionPatchStatus(version)) { + log('skipping version patch: $version'); + return; + } + log('applying version patch: $version'); + + StatsDB().deleteMistakes([ + 'wqhub://t/19030001665c', + 'wqhub://t/1a000001cc1a', + 'wqhub://t/0f000001891f', + ].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/lib/wq/region.dart b/lib/wq/region.dart new file mode 100644 index 00000000..e3f4146b --- /dev/null +++ b/lib/wq/region.dart @@ -0,0 +1,18 @@ +import 'package:wqhub/wq/wq.dart'; + +void visitRegion(Point initial, + {required bool Function(Point) shouldVisit, + required void Function(Point) visit}) { + dfs(Point p) { + if (shouldVisit(p)) { + final (r, c) = p; + visit(p); + dfs((r + 1, c)); + dfs((r - 1, c)); + dfs((r, c + 1)); + dfs((r, c - 1)); + } + } + + dfs(initial); +} 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..f29e3d61 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.11+28 environment: sdk: ^3.6.0 @@ -33,32 +33,38 @@ dependencies: convert: ^3.1.2 crypto: ^3.0.6 cupertino_icons: ^1.0.8 + device_info_plus: ^12.2.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 intl: ^0.20.1 logging: ^1.3.0 - package_info_plus: ^8.2.1 + package_info_plus: ^9.0.0 path: ^1.9.0 path_provider: ^2.1.5 - petitparser: ^6.0.2 - protobuf: ^3.1.0 - share_plus: ^10.1.4 + petitparser: ^7.0.1 + protobuf: ^5.1.0 + share_plus: ^12.0.1 shared_preferences: ^2.3.5 - sqlite3: ^2.7.5 + sqlite3: ^2.9.4 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 diff --git a/test/chat_presence_manager_test.dart b/test/chat_presence_manager_test.dart new file mode 100644 index 00000000..e822446c --- /dev/null +++ b/test/chat_presence_manager_test.dart @@ -0,0 +1,104 @@ +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/chat_presence_manager.dart'; +import 'package:wqhub/game_client/ogs/ogs_websocket_manager.dart'; + +void main() { + group('ChatPresenceManager', () { + late StreamController serverToClient; + late StreamController clientToServer; + late OGSWebSocketManager webSocketManager; + + setUp(() async { + serverToClient = StreamController(); + clientToServer = StreamController(); + + final mockChannel = + StreamChannel(serverToClient.stream, clientToServer.sink); + + webSocketManager = OGSWebSocketManager( + serverUrl: 'wss://test', + createChannel: (_) => mockChannel, + ); + + await webSocketManager.connect(); + }); + + tearDown(() async { + await webSocketManager.disconnect(); + await serverToClient.close(); + await clientToServer.close(); + webSocketManager.dispose(); + }); + + test('auto-joins on construction and auto-leaves on disposal', () async { + final manager = ChatPresenceManager( + channel: 'game-123', + webSocketManager: webSocketManager, + ); + + expect(manager.isPresent('user1'), false); + + // Should auto-leave on dispose + manager.dispose(); + + // Verify both messages were sent in order + await expectLater( + clientToServer.stream.map((msg) => jsonDecode(msg.toString())), + emitsInOrder([ + predicate((List data) => + data[0] == 'chat/join' && data[1]['channel'] == 'game-123'), + predicate((List data) => + data[0] == 'chat/part' && data[1]['channel'] == 'game-123'), + ]), + ); + }); + + test('tracks users joining and leaving the channel', () async { + clientToServer.stream.listen((_) {}); + + final manager = ChatPresenceManager( + channel: 'game-123', + webSocketManager: webSocketManager, + ); + + // Users join + serverToClient.add(jsonEncode([ + 'chat-join', + { + 'channel': 'game-123', + 'users': [ + {'id': 1, 'username': 'alice'}, + {'id': 2, 'username': 'bob'}, + ], + } + ])); + + // One user leaves + serverToClient.add(jsonEncode([ + 'chat-part', + { + 'channel': 'game-123', + 'user': {'id': 1, 'username': 'alice'}, + } + ])); + + // Verify presence updates + await expectLater( + manager.presenceUpdates, + emitsInOrder([ + {'1', '2'}, + {'2'}, + ]), + ); + + expect(manager.isPresent('1'), false); + expect(manager.isPresent('2'), true); + + manager.dispose(); + }); + }); +} 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_game_utils_test.dart b/test/ogs_game_utils_test.dart new file mode 100644 index 00000000..d7057595 --- /dev/null +++ b/test/ogs_game_utils_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wqhub/game_client/ogs/game_utils.dart'; +import 'package:wqhub/wq/wq.dart' as wq; + +void main() { + group('parseStonesString', () { + test('parses valid SGF coordinates correctly', () { + // Test basic parsing + final points = parseStonesString('aabbcc'); + expect(points.length, equals(3)); + expect(points[0], equals((0, 0))); // 'aa' -> (0, 0) + expect(points[1], equals((1, 1))); // 'bb' -> (1, 1) + expect(points[2], equals((2, 2))); // 'cc' -> (2, 2) + }); + + test('handles empty strings', () { + final emptyResult = parseStonesString(''); + expect(emptyResult, isEmpty); + }); + }); + + group('colorToMove', () { + test('basic alternating turns - no handicap or handicap 1', () { + // Handicap 0 (even game) should start with black + expect(colorToMove(0), equals(wq.Color.black)); + expect(colorToMove(1), equals(wq.Color.white)); + expect(colorToMove(2), equals(wq.Color.black)); + expect(colorToMove(3), equals(wq.Color.white)); + expect(colorToMove(10), equals(wq.Color.black)); + expect(colorToMove(11), equals(wq.Color.white)); + + // Handicap 1 also starts with black + expect(colorToMove(0, handicap: 1), equals(wq.Color.black)); + expect(colorToMove(1, handicap: 1), equals(wq.Color.white)); + expect(colorToMove(2, handicap: 1), equals(wq.Color.black)); + }); + + test('handicap game with pre-placed stones (Japanese)', () { + // With pre-placed handicap stones (handicap > 1), white plays first + expect(colorToMove(0, handicap: 2), equals(wq.Color.white)); + expect(colorToMove(1, handicap: 2), equals(wq.Color.black)); + expect(colorToMove(2, handicap: 2), equals(wq.Color.white)); + expect(colorToMove(3, handicap: 2), equals(wq.Color.black)); + + expect(colorToMove(0, handicap: 9), equals(wq.Color.white)); + expect(colorToMove(1, handicap: 9), equals(wq.Color.black)); + }); + + test('handicap game with free placement (Chinese)', () { + // With free handicap placement, black plays first to place handicap stones + // After handicap stones are placed, the game continues alternating + expect(colorToMove(0, handicap: 2, freeHandicapPlacement: true), + equals(wq.Color.black)); // Placing first handicap stone + expect(colorToMove(1, handicap: 2, freeHandicapPlacement: true), + equals(wq.Color.black)); // Placing second handicap stone + expect( + colorToMove(2, handicap: 2, freeHandicapPlacement: true), + equals(wq.Color + .white)); // First regular move (white starts after handicap) + expect(colorToMove(3, handicap: 2, freeHandicapPlacement: true), + equals(wq.Color.black)); // Second regular move + + expect(colorToMove(0, handicap: 5, freeHandicapPlacement: true), + equals(wq.Color.black)); // Placing handicap stones + expect(colorToMove(4, handicap: 5, freeHandicapPlacement: true), + equals(wq.Color.black)); // Still placing handicap stones + expect( + colorToMove(5, handicap: 5, freeHandicapPlacement: true), + equals(wq.Color + .white)); // First regular move after all handicap stones placed + }); + + test('free handicap 0 and 1 cases', () { + // No handicap should always start with black regardless of freeHandicapPlacement + expect(colorToMove(0, handicap: 0, freeHandicapPlacement: true), + equals(wq.Color.black)); + expect(colorToMove(1, handicap: 0, freeHandicapPlacement: true), + equals(wq.Color.white)); + + // Handicap 1 also starts with black + expect(colorToMove(0, handicap: 1, freeHandicapPlacement: true), + equals(wq.Color.black)); + }); + }); +} 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..ecd22cee 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,113 @@ 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']); + }); + + test('fromSgf with handicap stones', () { + // Test SGF with handicap stones using AB property + const sgfWithHandicap = + '(;GM[1]FF[4]SZ[19]HA[4]AB[pd][dp][pp][dd];W[qf];B[nc];W[fc])'; + final rec = GameRecord.fromSgf(sgfWithHandicap); + + expect(rec.moves.length, 7); + + // First 4 moves should be handicap stones (black) + expect(rec.moves[0].col, wq.Color.black); + expect(rec.moves[0].p, (3, 15)); // pd + expect(rec.moves[1].col, wq.Color.black); + expect(rec.moves[1].p, (15, 3)); // dp + expect(rec.moves[2].col, wq.Color.black); + expect(rec.moves[2].p, (15, 15)); // pp + expect(rec.moves[3].col, wq.Color.black); + expect(rec.moves[3].p, (3, 3)); // dd + + // Then the actual moves + expect(rec.moves[4].col, wq.Color.white); + expect(rec.moves[4].p, (5, 16)); // qf + expect(rec.moves[5].col, wq.Color.black); + expect(rec.moves[5].p, (2, 13)); // nc + expect(rec.moves[6].col, wq.Color.white); + expect(rec.moves[6].p, (2, 5)); // fc + }); } diff --git a/test/symmetry_test.dart b/test/symmetry_test.dart new file mode 100644 index 00000000..257c758d --- /dev/null +++ b/test/symmetry_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wqhub/symmetry.dart'; + +void main() { + group('transformPoint', () { + test('tengen transformations @ 19x19', () { + final boardSize = 19; + final tengen = (9, 9); + + expect(Symmetry.identity.transformPoint(tengen, boardSize), tengen); + + expect(Symmetry.diagonal1.transformPoint(tengen, boardSize), tengen); + expect(Symmetry.diagonal2.transformPoint(tengen, boardSize), tengen); + + expect(Symmetry.mirror1.transformPoint(tengen, boardSize), tengen); + expect(Symmetry.mirror2.transformPoint(tengen, boardSize), tengen); + + expect(Symmetry.rotate1.transformPoint(tengen, boardSize), tengen); + expect(Symmetry.rotate2.transformPoint(tengen, boardSize), tengen); + expect(Symmetry.rotate3.transformPoint(tengen, boardSize), tengen); + }); + + test('point (1,2) transformations @ 19x19', () { + final boardSize = 19; + final p1_2 = (1, 2); + + expect(Symmetry.identity.transformPoint(p1_2, boardSize), (1, 2)); + + expect(Symmetry.diagonal1.transformPoint(p1_2, boardSize), (2, 1)); + expect(Symmetry.diagonal2.transformPoint(p1_2, boardSize), (16, 17)); + + expect(Symmetry.mirror1.transformPoint(p1_2, boardSize), (17, 2)); + expect(Symmetry.mirror2.transformPoint(p1_2, boardSize), (1, 16)); + + expect(Symmetry.rotate1.transformPoint(p1_2, boardSize), (2, 17)); + expect(Symmetry.rotate2.transformPoint(p1_2, boardSize), (17, 16)); + expect(Symmetry.rotate3.transformPoint(p1_2, boardSize), (16, 1)); + }); + + test('point (0,0) transformations @ 4x4', () { + final boardSize = 4; + final zero = (0, 0); + + expect(Symmetry.identity.transformPoint(zero, boardSize), (0, 0)); + + expect(Symmetry.diagonal1.transformPoint(zero, boardSize), (0, 0)); + expect(Symmetry.diagonal2.transformPoint(zero, boardSize), (3, 3)); + + expect(Symmetry.mirror1.transformPoint(zero, boardSize), (3, 0)); + expect(Symmetry.mirror2.transformPoint(zero, boardSize), (0, 3)); + + expect(Symmetry.rotate1.transformPoint(zero, boardSize), (0, 3)); + expect(Symmetry.rotate2.transformPoint(zero, boardSize), (3, 3)); + expect(Symmetry.rotate3.transformPoint(zero, boardSize), (3, 0)); + }); + }); +} diff --git a/test/task_tag_test.dart b/test/task_tag_test.dart new file mode 100644 index 00000000..cc216dfc --- /dev/null +++ b/test/task_tag_test.dart @@ -0,0 +1,19 @@ +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) { + final ranks = tag.ranks(); + for (final rankRange in ranks) { + expect(rankRange.from.index <= rankRange.to.index, true, + reason: 'tag $tag: bad range $rankRange'); + } + for (int i = 0; i + 1 < ranks.length; ++i) { + expect(ranks[i].to.index < ranks[i + 1].from.index, true, + reason: + 'tag: $tag: bad range transition ${ranks[i]} -> ${ranks[i + 1]}'); + } + } + }); +} 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"