diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6428964..dfc6e76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,56 +169,6 @@ jobs: - name: Run tests run: flutter test --coverage - # Temporarily disabled: app entry still imports dart:io unconditionally - # (LogProvider, BackupService, etc.), so `flutter build web` fails. - # Re-enable after conditional imports / stubs land; set repo var ENABLE_WEB_BUILD=true. - web: - name: Build Web - if: ${{ vars.ENABLE_WEB_BUILD == 'true' }} - runs-on: ubuntu-latest - needs: [prepare, quality] - env: - ARTIFACT_VERSION: ${{ needs.prepare.outputs.artifact_version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - pub-cache: true - - - name: Enable Web - run: flutter config --enable-web - - - name: Install dependencies - run: flutter pub get - - - name: Build Web release - run: flutter build web --release - - - name: Package Web release - shell: bash - run: | - set -euo pipefail - test -d build/web - test -n "$(find build/web -type f -print -quit)" - mkdir -p artifacts - (cd build && zip -qr "../artifacts/Papyrus-web-${ARTIFACT_VERSION}.zip" web) - test -s "artifacts/Papyrus-web-${ARTIFACT_VERSION}.zip" - - - name: Upload Web package - uses: actions/upload-artifact@v4 - with: - name: package-web - path: artifacts/Papyrus-web-${{ needs.prepare.outputs.artifact_version }}.zip - if-no-files-found: error - retention-days: 7 - compression-level: 0 - android: name: Build Android if: ${{ github.event_name != 'pull_request' }} @@ -278,207 +228,11 @@ jobs: retention-days: 7 compression-level: 0 - windows: - name: Build Windows - if: ${{ github.event_name != 'pull_request' }} - runs-on: windows-latest - needs: [prepare, quality] - env: - BUILD_NAME: ${{ needs.prepare.outputs.build_name }} - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - ARTIFACT_VERSION: ${{ needs.prepare.outputs.artifact_version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - pub-cache: true - - - name: Enable Windows desktop - run: flutter config --enable-windows-desktop - - - name: Install dependencies - run: flutter pub get - - - name: Build Windows release - run: flutter build windows --release --build-name $env:BUILD_NAME --build-number $env:BUILD_NUMBER - - - name: Package Windows release - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $releaseDir = Join-Path $PWD 'build/windows/x64/runner/Release' - if (-not (Test-Path $releaseDir)) { throw "Missing Windows release directory: $releaseDir" } - if (-not (Get-ChildItem $releaseDir -File -Recurse)) { throw 'Windows release directory is empty' } - $artifactDir = Join-Path $PWD 'artifacts' - New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null - $zipPath = Join-Path $artifactDir "Papyrus-windows-x64-$env:ARTIFACT_VERSION.zip" - Compress-Archive -Path $releaseDir -DestinationPath $zipPath -Force - if ((Get-Item $zipPath).Length -le 0) { throw 'Windows package is empty' } - - - name: Upload Windows package - uses: actions/upload-artifact@v4 - with: - name: package-windows - path: artifacts/Papyrus-windows-x64-${{ needs.prepare.outputs.artifact_version }}.zip - if-no-files-found: error - retention-days: 7 - compression-level: 0 - - linux: - name: Build Linux - if: ${{ github.event_name != 'pull_request' }} - runs-on: ubuntu-latest - needs: [prepare, quality] - env: - ARTIFACT_VERSION: ${{ needs.prepare.outputs.artifact_version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Linux build dependencies - run: | - sudo apt-get update - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - pub-cache: true - - - name: Enable Linux desktop - run: flutter config --enable-linux-desktop - - - name: Install dependencies - run: flutter pub get - - - name: Build Linux release - run: flutter build linux --release - - - name: Package Linux release - shell: bash - run: | - set -euo pipefail - bundle_dir=build/linux/x64/release/bundle - test -d "$bundle_dir" - test -n "$(find "$bundle_dir" -type f -print -quit)" - mkdir -p artifacts - tar -czf "artifacts/Papyrus-linux-x64-${ARTIFACT_VERSION}.tar.gz" \ - -C build/linux/x64/release bundle - test -s "artifacts/Papyrus-linux-x64-${ARTIFACT_VERSION}.tar.gz" - - - name: Upload Linux package - uses: actions/upload-artifact@v4 - with: - name: package-linux - path: artifacts/Papyrus-linux-x64-${{ needs.prepare.outputs.artifact_version }}.tar.gz - if-no-files-found: error - retention-days: 7 - compression-level: 0 - - macos: - name: Build macOS - if: ${{ github.event_name != 'pull_request' }} - runs-on: macos-latest - needs: [prepare, quality] - env: - BUILD_NAME: ${{ needs.prepare.outputs.build_name }} - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - ARTIFACT_VERSION: ${{ needs.prepare.outputs.artifact_version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - pub-cache: true - - - name: Enable macOS desktop - run: flutter config --enable-macos-desktop - - - name: Install dependencies - run: flutter pub get - - - name: Build macOS release - run: flutter build macos --release --build-name "$BUILD_NAME" --build-number "$BUILD_NUMBER" - - - name: Package macOS release - shell: bash - run: | - set -euo pipefail - app_path="$(find build/macos/Build/Products/Release -maxdepth 1 -type d -name '*.app' -print -quit)" - if [[ -z "$app_path" ]]; then - echo 'No macOS .app bundle was produced' - exit 1 - fi - mkdir -p artifacts - ditto -c -k --sequesterRsrc --keepParent "$app_path" \ - "artifacts/Papyrus-macos-${ARTIFACT_VERSION}.zip" - test -s "artifacts/Papyrus-macos-${ARTIFACT_VERSION}.zip" - - - name: Upload macOS package - uses: actions/upload-artifact@v4 - with: - name: package-macos - path: artifacts/Papyrus-macos-${{ needs.prepare.outputs.artifact_version }}.zip - if-no-files-found: error - retention-days: 7 - compression-level: 0 - - ios: - name: Validate iOS build - if: ${{ github.event_name != 'pull_request' }} - runs-on: macos-latest - needs: [prepare, quality] - env: - BUILD_NAME: ${{ needs.prepare.outputs.build_name }} - BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - pub-cache: true - - - name: Install dependencies - run: flutter pub get - - - name: Build iOS without code signing - run: flutter build ios --release --no-codesign --build-name "$BUILD_NAME" --build-number "$BUILD_NUMBER" - - - name: Verify iOS app bundle - shell: bash - run: | - set -euo pipefail - app_path="$(find build/ios/iphoneos -maxdepth 1 -type d -name '*.app' -print -quit)" - if [[ -z "$app_path" ]]; then - echo 'No iOS .app bundle was produced' - exit 1 - fi - echo "Validated unsigned iOS bundle: $app_path" - publish: name: Publish GitHub Release runs-on: ubuntu-latest - needs: [prepare, quality, web, android, windows, linux, macos, ios] - if: ${{ always() && needs.prepare.outputs.should_release == 'true' && needs.quality.result == 'success' && (needs.web.result == 'success' || needs.web.result == 'skipped') && needs.android.result == 'success' && needs.windows.result == 'success' && needs.linux.result == 'success' && needs.macos.result == 'success' && needs.ios.result == 'success' }} + needs: [prepare, quality, android] + if: ${{ always() && needs.prepare.outputs.should_release == 'true' && needs.quality.result == 'success' && needs.android.result == 'success' }} permissions: contents: write env: @@ -486,7 +240,6 @@ jobs: TAG_NAME: ${{ needs.prepare.outputs.tag_name }} IS_DRAFT: ${{ needs.prepare.outputs.is_draft }} IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }} - WEB_BUILT: ${{ needs.web.result == 'success' }} steps: - name: Download platform packages uses: actions/download-artifact@v4 @@ -501,17 +254,10 @@ jobs: set -euo pipefail expected=( - "release-assets/Papyrus-windows-x64-${ARTIFACT_VERSION}.zip" - "release-assets/Papyrus-macos-${ARTIFACT_VERSION}.zip" - "release-assets/Papyrus-linux-x64-${ARTIFACT_VERSION}.tar.gz" "release-assets/Papyrus-android-${ARTIFACT_VERSION}.apk" "release-assets/Papyrus-android-${ARTIFACT_VERSION}.aab" ) - if [[ "$WEB_BUILT" == "true" ]]; then - expected+=("release-assets/Papyrus-web-${ARTIFACT_VERSION}.zip") - fi - for file in "${expected[@]}"; do if [[ ! -s "$file" ]]; then echo "Missing or empty release asset: $file" @@ -519,23 +265,10 @@ jobs: fi done - unzip -t "release-assets/Papyrus-windows-x64-${ARTIFACT_VERSION}.zip" >/dev/null - unzip -t "release-assets/Papyrus-macos-${ARTIFACT_VERSION}.zip" >/dev/null - tar -tzf "release-assets/Papyrus-linux-x64-${ARTIFACT_VERSION}.tar.gz" >/dev/null - if [[ "$WEB_BUILT" == "true" ]]; then - unzip -t "release-assets/Papyrus-web-${ARTIFACT_VERSION}.zip" >/dev/null - fi - checksum_files=( - "Papyrus-windows-x64-${ARTIFACT_VERSION}.zip" - "Papyrus-macos-${ARTIFACT_VERSION}.zip" - "Papyrus-linux-x64-${ARTIFACT_VERSION}.tar.gz" "Papyrus-android-${ARTIFACT_VERSION}.apk" "Papyrus-android-${ARTIFACT_VERSION}.aab" ) - if [[ "$WEB_BUILT" == "true" ]]; then - checksum_files+=("Papyrus-web-${ARTIFACT_VERSION}.zip") - fi ( cd release-assets sha256sum "${checksum_files[@]}" diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..d7aa7f2 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..657694a 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..bb972b9 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..4b1e4d8 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..a911e09 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/icons/app_icon.png b/assets/icons/app_icon.png new file mode 100644 index 0000000..cd696c6 Binary files /dev/null and b/assets/icons/app_icon.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..d0d98aa 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..cd696c6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..362728f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..f8db51f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..3299734 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..c807029 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..17f8b73 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..57d8c38 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..f8db51f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..9a5b917 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..828c295 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..86aba52 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..4820cb0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..7fca7ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..888042d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..828c295 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..3e7d8e8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..d7aa7f2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..4b1e4d8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..1f1193b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..53f18ed 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..5b4c80f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/app.dart b/lib/app.dart index d96fd33..46e809b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; @@ -6,6 +8,7 @@ import 'data/repositories/ai_config_repository_impl.dart'; import 'data/repositories/ai_session_repository_impl.dart'; import 'data/repositories/card_repository_impl.dart'; import 'logging/logger.dart'; +import 'mcp/mcp_runtime.dart'; import 'presentation/navigation/app_navigation.dart'; import 'presentation/providers/ai_chat_provider.dart'; import 'presentation/providers/ai_config_provider.dart'; @@ -37,21 +40,28 @@ class _AppContent extends StatelessWidget { providers: [ // Repositories Provider(create: (_) => CardRepositoryImpl(db)), - Provider(create: (_) => AIConfigRepositoryImpl(db)), - Provider(create: (_) => AISessionRepositoryImpl(db)), + Provider( + create: (_) => AIConfigRepositoryImpl(db), + ), + Provider( + create: (_) => AISessionRepositoryImpl(db), + ), // Providers ChangeNotifierProvider( create: (context) => CardProvider(context.read()), ), ChangeNotifierProvider( - create: (context) => StudyProvider(context.read()), + create: (context) => + StudyProvider(context.read()), ), ChangeNotifierProvider( - create: (context) => SessionProvider(context.read()), + create: (context) => + SessionProvider(context.read()), ), ChangeNotifierProvider( - create: (context) => AIConfigProvider(context.read()), + create: (context) => + AIConfigProvider(context.read()), ), ChangeNotifierProvider( create: (context) => AIChatProvider( @@ -60,34 +70,68 @@ class _AppContent extends StatelessWidget { context.read(), ), ), - ChangeNotifierProvider( - create: (_) => LogProvider(db), - ), + ChangeNotifierProvider(create: (_) => LogProvider(db)), ], - child: FluentApp( - title: 'Papyrus', - debugShowCheckedModeBanner: false, - theme: FluentThemeData( - accentColor: Colors.blue.toAccentColor(), - visualDensity: VisualDensity.standard, - ), - darkTheme: FluentThemeData( - brightness: Brightness.dark, - accentColor: Colors.blue.toAccentColor(), - visualDensity: VisualDensity.standard, + child: _MCPServiceLifecycle( + child: FluentApp( + title: 'Papyrus', + debugShowCheckedModeBanner: false, + theme: FluentThemeData( + accentColor: Colors.blue.toAccentColor(), + visualDensity: VisualDensity.standard, + ), + darkTheme: FluentThemeData( + brightness: Brightness.dark, + accentColor: Colors.blue.toAccentColor(), + visualDensity: VisualDensity.standard, + ), + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + FluentLocalizations.delegate, + ], + supportedLocales: const [Locale('zh'), Locale('en')], + home: const AppNavigation(), ), - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - FluentLocalizations.delegate, - ], - supportedLocales: const [ - Locale('zh'), - Locale('en'), - ], - home: const AppNavigation(), ), ); } } + +class _MCPServiceLifecycle extends StatefulWidget { + final Widget child; + + const _MCPServiceLifecycle({required this.child}); + + @override + State<_MCPServiceLifecycle> createState() => _MCPServiceLifecycleState(); +} + +class _MCPServiceLifecycleState extends State<_MCPServiceLifecycle> { + @override + void initState() { + super.initState(); + unawaited(_startService()); + } + + Future _startService() async { + try { + await startMCPService(); + } catch (error) { + Logger.instance.error( + 'MCP server failed to start', + metadata: {'error': error.toString()}, + ); + } + } + + @override + void dispose() { + unawaited(stopMCPService()); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/data/repositories/card_repository.dart b/lib/data/repositories/card_repository.dart index 6a495c4..6755793 100644 --- a/lib/data/repositories/card_repository.dart +++ b/lib/data/repositories/card_repository.dart @@ -2,6 +2,7 @@ import '../models/card_model.dart'; abstract class CardRepository { Future> loadAll(); + Future countCards(); Future> getDueCards(); Future saveAll(List cards); Future add(CardModel card); diff --git a/lib/data/repositories/card_repository_impl.dart b/lib/data/repositories/card_repository_impl.dart index 98e7ab9..40fa57d 100644 --- a/lib/data/repositories/card_repository_impl.dart +++ b/lib/data/repositories/card_repository_impl.dart @@ -10,27 +10,27 @@ class CardRepositoryImpl implements CardRepository { CardRepositoryImpl(this._db); CardModel _toModel(Card c) => CardModel( - id: c.id, - q: c.q, - a: c.a, - nextReview: c.nextReview, - interval: c.interval, - ef: c.ef, - repetitions: c.repetitions, - tags: c.tags.isEmpty ? [] : c.tags.split(','), - ); + id: c.id, + q: c.q, + a: c.a, + nextReview: c.nextReview, + interval: c.interval, + ef: c.ef, + repetitions: c.repetitions, + tags: c.tags.isEmpty ? [] : c.tags.split(','), + ); CardsCompanion _toCompanion(CardModel card) => CardsCompanion( - id: Value(card.id), - q: Value(card.q), - a: Value(card.a), - nextReview: Value(card.nextReview), - interval: Value(card.interval), - ef: Value(card.ef), - repetitions: Value(card.repetitions), - tags: Value(card.tags.join(',')), - createdAt: Value(card.createdAt), - ); + id: Value(card.id), + q: Value(card.q), + a: Value(card.a), + nextReview: Value(card.nextReview), + interval: Value(card.interval), + ef: Value(card.ef), + repetitions: Value(card.repetitions), + tags: Value(card.tags.join(',')), + createdAt: Value(card.createdAt), + ); @override Future> loadAll() async { @@ -38,6 +38,14 @@ class CardRepositoryImpl implements CardRepository { return rows.map(_toModel).toList(); } + @override + Future countCards() async { + final count = _db.cards.id.count(); + final query = _db.selectOnly(_db.cards)..addColumns([count]); + final row = await query.getSingle(); + return row.read(count) ?? 0; + } + @override Future> getDueCards() async { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -77,8 +85,7 @@ class CardRepositoryImpl implements CardRepository { Future> search(String keyword) async { final lower = '%${keyword.toLowerCase()}%'; final query = _db.select(_db.cards) - ..where((c) => - c.q.lower().like(lower) | c.a.lower().like(lower)); + ..where((c) => c.q.lower().like(lower) | c.a.lower().like(lower)); final rows = await query.get(); return rows.map(_toModel).toList(); } @@ -95,17 +102,21 @@ class CardRepositoryImpl implements CardRepository { final q = parts[0].trim(); final a = parts[1].trim(); if (q.isNotEmpty && a.isNotEmpty) { - cards.add(CardsCompanion( - id: Value(DateTime.now().millisecondsSinceEpoch.toString() + '_${cards.length}'), - q: Value(q), - a: Value(a), - nextReview: const Value(AppConstants.defaultNextReview), - interval: const Value(AppConstants.defaultInterval), - ef: const Value(AppConstants.defaultEasinessFactor), - repetitions: const Value(AppConstants.defaultRepetitions), - tags: const Value(''), - createdAt: Value(DateTime.now().millisecondsSinceEpoch ~/ 1000), - )); + cards.add( + CardsCompanion( + id: Value( + '${DateTime.now().millisecondsSinceEpoch}_${cards.length}', + ), + q: Value(q), + a: Value(a), + nextReview: const Value(AppConstants.defaultNextReview), + interval: const Value(AppConstants.defaultInterval), + ef: const Value(AppConstants.defaultEasinessFactor), + repetitions: const Value(AppConstants.defaultRepetitions), + tags: const Value(''), + createdAt: Value(DateTime.now().millisecondsSinceEpoch ~/ 1000), + ), + ); } } } diff --git a/lib/mcp/mcp_isolate.dart b/lib/mcp/mcp_isolate.dart index c15deab..b88cc85 100644 --- a/lib/mcp/mcp_isolate.dart +++ b/lib/mcp/mcp_isolate.dart @@ -1,6 +1,8 @@ import 'dart:isolate'; +import 'package:flutter/services.dart'; import '../data/local/app_database.dart'; import '../data/repositories/card_repository_impl.dart'; +import '../logging/logger.dart'; import 'mcp_server.dart'; /// MCP 服务器 Isolate 包装器 @@ -8,42 +10,87 @@ class MCPIsolate { static SendPort? _sendPort; static Isolate? _isolate; - static Future start({String host = '127.0.0.1', int port = 8787}) async { + static Future start({ + String host = '127.0.0.1', + int port = 8787, + }) async { if (_isolate != null) return; + final rootToken = RootIsolateToken.instance; + if (rootToken == null) { + throw StateError( + 'MCP server must be started from the Flutter root isolate', + ); + } + final receivePort = ReceivePort(); _isolate = await Isolate.spawn( _isolateEntry, _IsolateInitMessage( sendPort: receivePort.sendPort, + rootToken: rootToken, host: host, port: port, ), ); - _sendPort = await receivePort.first as SendPort; + final readyMessage = await receivePort.first; + receivePort.close(); + if (readyMessage is! Map) { + _isolate?.kill(); + _isolate = null; + throw StateError('MCP isolate returned an invalid startup response'); + } + final error = readyMessage['error']; + if (error != null) { + _isolate?.kill(); + _isolate = null; + throw StateError('MCP server failed to start: $error'); + } + _sendPort = readyMessage['sendPort'] as SendPort; } static Future stop() async { - _sendPort?.send('stop'); + final sendPort = _sendPort; + if (sendPort != null) { + final stopped = ReceivePort(); + sendPort.send(['stop', stopped.sendPort]); + await stopped.first; + stopped.close(); + } _isolate?.kill(); _isolate = null; _sendPort = null; } static void _isolateEntry(_IsolateInitMessage init) async { + BackgroundIsolateBinaryMessenger.ensureInitialized(init.rootToken); final receivePort = ReceivePort(); - init.sendPort.send(receivePort.sendPort); + AppDatabase? db; + MCPServer? server; - final db = AppDatabase.defaults(); - final repo = CardRepositoryImpl(db); - final server = MCPServer(repo); - await server.start(host: init.host, port: init.port); + try { + db = AppDatabase.defaults(); + Logger.instance.initialize(db); + final repo = CardRepositoryImpl(db); + server = MCPServer(repo); + await server.start(host: init.host, port: init.port); + init.sendPort.send({'sendPort': receivePort.sendPort}); + } catch (error) { + init.sendPort.send({'error': error.toString()}); + receivePort.close(); + await db?.close(); + return; + } await for (final message in receivePort) { - if (message == 'stop') { + if (message is List && message.firstOrNull == 'stop') { await server.stop(); await db.close(); + if (message.length > 1 && message[1] is SendPort) { + (message[1] as SendPort).send('stopped'); + } + receivePort.close(); break; } } @@ -52,11 +99,13 @@ class MCPIsolate { class _IsolateInitMessage { final SendPort sendPort; + final RootIsolateToken rootToken; final String host; final int port; _IsolateInitMessage({ required this.sendPort, + required this.rootToken, required this.host, required this.port, }); diff --git a/lib/mcp/mcp_runtime.dart b/lib/mcp/mcp_runtime.dart new file mode 100644 index 0000000..b7e3ef2 --- /dev/null +++ b/lib/mcp/mcp_runtime.dart @@ -0,0 +1 @@ +export 'mcp_runtime_stub.dart' if (dart.library.io) 'mcp_runtime_io.dart'; diff --git a/lib/mcp/mcp_runtime_io.dart b/lib/mcp/mcp_runtime_io.dart new file mode 100644 index 0000000..6d2d133 --- /dev/null +++ b/lib/mcp/mcp_runtime_io.dart @@ -0,0 +1,5 @@ +import 'mcp_isolate.dart'; + +Future startMCPService() => MCPIsolate.start(); + +Future stopMCPService() => MCPIsolate.stop(); diff --git a/lib/mcp/mcp_runtime_stub.dart b/lib/mcp/mcp_runtime_stub.dart new file mode 100644 index 0000000..b6dd15e --- /dev/null +++ b/lib/mcp/mcp_runtime_stub.dart @@ -0,0 +1,3 @@ +Future startMCPService() async {} + +Future stopMCPService() async {} diff --git a/lib/mcp/mcp_server.dart b/lib/mcp/mcp_server.dart index 17ac579..9812f3b 100644 --- a/lib/mcp/mcp_server.dart +++ b/lib/mcp/mcp_server.dart @@ -1,8 +1,8 @@ import 'dart:convert'; import 'dart:io'; -import '../ai/services/tool_parser.dart'; -import '../ai/tools/tool_executor.dart'; import '../data/repositories/card_repository.dart'; +import '../logging/logger.dart'; +import 'mcp_tool_handler.dart'; /// MCP HTTP 服务器 class MCPServer { @@ -12,29 +12,41 @@ class MCPServer { MCPServer(this._cardRepository); bool get isRunning => _server != null; + int? get boundPort => _server?.port; Future start({String host = '127.0.0.1', int port = 8787}) async { if (_server != null) return; _server = await HttpServer.bind(host, port); - print('MCP Server running on http://$host:$port'); - - await for (final request in _server!) { - _handleRequest(request); - } + _server!.listen(_handleRequest); + Logger.instance.info( + 'MCP server started', + metadata: {'host': host, 'port': port}, + ); } Future stop() async { - await _server?.close(); + await _server?.close(force: true); _server = null; - print('MCP Server stopped'); + Logger.instance.info('MCP server stopped'); } void _handleRequest(HttpRequest request) { + Logger.instance.event( + 'MCP request', + metadata: {'method': request.method, 'path': request.uri.path}, + ); + // CORS 头 request.response.headers.add('Access-Control-Allow-Origin', '*'); - request.response.headers.add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - request.response.headers.add('Access-Control-Allow-Headers', 'Content-Type'); + request.response.headers.add( + 'Access-Control-Allow-Methods', + 'GET, POST, OPTIONS', + ); + request.response.headers.add( + 'Access-Control-Allow-Headers', + 'Content-Type', + ); if (request.method == 'OPTIONS') { request.response.statusCode = HttpStatus.noContent; @@ -42,6 +54,8 @@ class MCPServer { return; } + request.response.headers.contentType = ContentType.json; + final path = request.uri.path; switch (path) { @@ -64,12 +78,28 @@ class MCPServer { void _handleHealth(HttpRequest request) { request.response ..statusCode = HttpStatus.ok - ..write(jsonEncode({'status': 'ok'})) + ..write( + jsonEncode({ + 'status': 'ok', + 'api_version': '1.1', + 'protocol': 'papyrus-mcp', + }), + ) ..close(); } void _handleTools(HttpRequest request) { final tools = [ + { + 'name': 'list_cards', + 'description': 'List cards, optionally filtered by query', + }, + {'name': 'get_card', 'description': 'Get a card by id'}, + {'name': 'get_due_cards', 'description': 'Get the current review queue'}, + { + 'name': 'review_card', + 'description': 'Submit a review grade for a card', + }, {'name': 'create_card', 'description': '创建新卡片'}, {'name': 'update_card', 'description': '更新卡片'}, {'name': 'delete_card', 'description': '删除卡片'}, @@ -102,13 +132,22 @@ class MCPServer { return; } - final executor = ToolExecutor(_cardRepository); - final result = await executor.execute(ToolCall(tool: tool, params: params)); + final result = await MCPToolHandler( + _cardRepository, + ).execute(tool, params); request.response ..statusCode = HttpStatus.ok ..write(jsonEncode({'result': result})) ..close(); + } on FormatException catch (e) { + request.response.statusCode = HttpStatus.badRequest; + request.response.write(jsonEncode({'error': e.message})); + request.response.close(); + } on MCPToolException catch (e) { + request.response.statusCode = HttpStatus.badRequest; + request.response.write(jsonEncode({'error': e.message})); + request.response.close(); } catch (e) { request.response.statusCode = HttpStatus.internalServerError; request.response.write(jsonEncode({'error': e.toString()})); diff --git a/lib/mcp/mcp_tool_handler.dart b/lib/mcp/mcp_tool_handler.dart new file mode 100644 index 0000000..ed69b88 --- /dev/null +++ b/lib/mcp/mcp_tool_handler.dart @@ -0,0 +1,158 @@ +import '../data/models/card_model.dart'; +import '../data/repositories/card_repository.dart'; +import '../domain/algorithms/sm2_engine.dart'; + +/// Executes structured card operations for the local MCP HTTP server. +class MCPToolHandler { + final CardRepository _cardRepository; + + MCPToolHandler(this._cardRepository); + + Future> execute( + String tool, + Map params, + ) async { + switch (tool) { + case 'create_card': + return _createCard(params); + case 'update_card': + return _updateCard(params); + case 'delete_card': + return _deleteCard(params); + case 'list_cards': + return _listCards(params); + case 'get_card': + return _getCard(params); + case 'search_cards': + return _searchCards(params); + case 'get_due_cards': + return _getDueCards(); + case 'review_card': + return _reviewCard(params); + case 'get_card_stats': + return _getCardStats(); + default: + throw MCPToolException('Unknown tool: $tool'); + } + } + + Future> _createCard(Map params) async { + final card = CardModel( + id: '${DateTime.now().microsecondsSinceEpoch}', + q: _requiredString(params, 'question'), + a: _requiredString(params, 'answer'), + tags: _tags(params['tags']), + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + await _cardRepository.add(card); + return {'card': card.toJson()}; + } + + Future> _updateCard(Map params) async { + final card = await _findCard(_requiredString(params, 'id')); + final updated = card.copyWith( + q: params['question'] is String ? params['question'] as String : card.q, + a: params['answer'] is String ? params['answer'] as String : card.a, + tags: params.containsKey('tags') ? _tags(params['tags']) : card.tags, + ); + await _cardRepository.update(updated); + return {'card': updated.toJson()}; + } + + Future> _deleteCard(Map params) async { + final card = await _findCard(_requiredString(params, 'id')); + await _cardRepository.delete(card.id); + return {'deleted_id': card.id}; + } + + Future> _listCards(Map params) async { + final query = params['query']; + final cards = query is String && query.trim().isNotEmpty + ? await _cardRepository.search(query.trim()) + : await _cardRepository.loadAll(); + return _cardsResult(cards); + } + + Future> _getCard(Map params) async { + return {'card': (await _findCard(_requiredString(params, 'id'))).toJson()}; + } + + Future> _searchCards(Map params) async { + return _cardsResult( + await _cardRepository.search(_requiredString(params, 'keyword')), + ); + } + + Future> _getDueCards() async { + final results = await Future.wait([ + _cardRepository.getDueCards(), + _cardRepository.countCards(), + ]); + final cards = results[0] as List; + return { + ..._cardsResult(cards), + 'due_count': cards.length, + 'total_cards': results[1] as int, + }; + } + + Future> _reviewCard(Map params) async { + final grade = params['grade']; + if (grade is! int || grade < 1 || grade > 3) { + throw MCPToolException('grade must be an integer from 1 to 3'); + } + final updated = SM2Engine.review( + await _findCard(_requiredString(params, 'id')), + grade, + ); + await _cardRepository.update(updated); + return {'card': updated.toJson()}; + } + + Future> _getCardStats() async { + final results = await Future.wait([ + _cardRepository.getDueCards(), + _cardRepository.countCards(), + ]); + return { + 'due_count': (results[0] as List).length, + 'total_cards': results[1] as int, + }; + } + + Map _cardsResult(List cards) => { + 'cards': cards.map((card) => card.toJson()).toList(), + 'total_cards': cards.length, + }; + + Future _findCard(String id) async { + final cards = await _cardRepository.loadAll(); + for (final card in cards) { + if (card.id == id) return card; + } + throw MCPToolException('Card not found: $id'); + } + + String _requiredString(Map params, String name) { + final value = params[name]; + if (value is! String || value.trim().isEmpty) { + throw MCPToolException('$name must be a non-empty string'); + } + return value.trim(); + } + + List _tags(Object? value) { + if (value == null) return const []; + if (value is! List || value.any((tag) => tag is! String)) { + throw MCPToolException('tags must be an array of strings'); + } + return value.cast(); + } +} + +class MCPToolException implements Exception { + final String message; + MCPToolException(this.message); + @override + String toString() => message; +} diff --git a/lib/presentation/navigation/app_navigation.dart b/lib/presentation/navigation/app_navigation.dart index 00f12db..c91f183 100644 --- a/lib/presentation/navigation/app_navigation.dart +++ b/lib/presentation/navigation/app_navigation.dart @@ -1,4 +1,5 @@ import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import '../providers/card_provider.dart'; import '../screens/study_screen.dart'; @@ -63,25 +64,26 @@ class _AppNavigationState extends State { @override Widget build(BuildContext context) { - return NavigationView( - titleBar: const TitleBar( - title: Text('Papyrus'), - ), - transitionBuilder: (child, animation) { - return EntrancePageTransition( - animation: animation, - child: child, - ); - }, - pane: NavigationPane( - selected: _selectedIndex, - onChanged: (index) => setState(() => _selectedIndex = index), - displayMode: PaneDisplayMode.auto, - indicator: const StickyNavigationIndicator(), - items: _items, - footerItems: _footerItems, - autoSuggestBox: _buildSearchBox(), - autoSuggestBoxReplacement: const Icon(WindowsIcons.search), + final isMobile = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + + return SafeArea( + child: NavigationView( + titleBar: isMobile ? null : const TitleBar(title: Text('Papyrus')), + transitionBuilder: (child, animation) { + return EntrancePageTransition(animation: animation, child: child); + }, + pane: NavigationPane( + selected: _selectedIndex, + onChanged: (index) => setState(() => _selectedIndex = index), + displayMode: PaneDisplayMode.auto, + indicator: const StickyNavigationIndicator(), + items: _items, + footerItems: _footerItems, + autoSuggestBox: _buildSearchBox(), + autoSuggestBoxReplacement: const Icon(WindowsIcons.search), + ), ), ); } @@ -90,21 +92,25 @@ class _AppNavigationState extends State { return Consumer( builder: (context, provider, _) { final cards = provider.cards; - final items = cards.map((card) => AutoSuggestBoxItem( - value: card.id, - label: card.q, - child: Text( - card.q, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onSelected: () { - _searchController.clear(); - // 跳转到卡片管理页并搜索 - setState(() => _selectedIndex = 2); - provider.search(card.q); - }, - )).toList(); + final items = cards + .map( + (card) => AutoSuggestBoxItem( + value: card.id, + label: card.q, + child: Text( + card.q, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onSelected: () { + _searchController.clear(); + // 跳转到卡片管理页并搜索 + setState(() => _selectedIndex = 2); + provider.search(card.q); + }, + ), + ) + .toList(); return AutoSuggestBox( controller: _searchController, diff --git a/lib/presentation/providers/study_provider.dart b/lib/presentation/providers/study_provider.dart index 0e21fd2..e14a08b 100644 --- a/lib/presentation/providers/study_provider.dart +++ b/lib/presentation/providers/study_provider.dart @@ -5,7 +5,7 @@ import '../../data/models/card_model.dart'; import '../../data/repositories/card_repository.dart'; import '../../domain/algorithms/sm2_engine.dart'; -enum StudyState { question, answer, empty } +enum StudyState { question, answer, noCards, noDueCards } class StudyProvider extends ChangeNotifier { final CardRepository _repository; @@ -14,7 +14,8 @@ class StudyProvider extends ChangeNotifier { List _dueCards = []; int _currentIndex = 0; - StudyState _state = StudyState.empty; + StudyState _state = StudyState.noCards; + int _totalCount = 0; bool _canGrade = false; Timer? _debounceTimer; Timer? _dueCheckTimer; @@ -23,21 +24,34 @@ class StudyProvider extends ChangeNotifier { StudyState get state => _state; bool get canGrade => _canGrade; bool get isLoading => _isLoading; - - CardModel? get currentCard => _dueCards.isNotEmpty && _currentIndex < _dueCards.length + + CardModel? get currentCard => + _dueCards.isNotEmpty && _currentIndex < _dueCards.length ? _dueCards[_currentIndex] : null; - + int get dueCount => _dueCards.length; + int get totalCount => _totalCount; int get currentIndex => _currentIndex; + int get remainingDueCount => + (_dueCards.length - _currentIndex).clamp(0, _dueCards.length); Future loadDueCards() async { _isLoading = true; notifyListeners(); - _dueCards = await _repository.getDueCards(); + final results = await Future.wait([ + _repository.getDueCards(), + _repository.countCards(), + ]); + _dueCards = results[0] as List; + _totalCount = results[1] as int; _currentIndex = 0; - _state = _dueCards.isNotEmpty ? StudyState.question : StudyState.empty; + _state = _dueCards.isNotEmpty + ? StudyState.question + : _totalCount == 0 + ? StudyState.noCards + : StudyState.noDueCards; _canGrade = false; _isLoading = false; _startDueCheckTimer(); @@ -77,7 +91,7 @@ class StudyProvider extends ChangeNotifier { void _startDueCheckTimer() { _dueCheckTimer?.cancel(); - if (_state == StudyState.empty) { + if (_state == StudyState.noCards || _state == StudyState.noDueCards) { _dueCheckTimer = Timer.periodic(AppConstants.dueCheckInterval, (_) { loadDueCards(); }); diff --git a/lib/presentation/screens/backup_screen.dart b/lib/presentation/screens/backup_screen.dart index 2b6dcd9..a40b2dd 100644 --- a/lib/presentation/screens/backup_screen.dart +++ b/lib/presentation/screens/backup_screen.dart @@ -45,46 +45,47 @@ class _BackupScreenState extends State { content: _isLoading ? const Center(child: ProgressRing()) : _backups.isEmpty - ? const Center(child: Text('暂无备份')) - : ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _backups.length, - itemBuilder: (context, index) { - final backup = _backups[index]; - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text(backup.name), - subtitle: backup.timestamp != null - ? Text('${backup.timestamp}') - : null, - leading: Icon( - backup.isAuto - ? WindowsIcons.calendar_day - : WindowsIcons.save_local, - size: 20, - color: backup.isAuto - ? Colors.orange - : Colors.blue, + ? const Center(child: Text('暂无备份')) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _backups.length, + itemBuilder: (context, index) { + final backup = _backups[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(backup.name), + subtitle: backup.timestamp != null + ? Text('${backup.timestamp}') + : null, + leading: Icon( + backup.isAuto + ? WindowsIcons.calendar_day + : WindowsIcons.save_local, + size: 20, + color: backup.isAuto ? Colors.orange : Colors.blue, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Button( + onPressed: () => _restoreBackup(backup), + child: const Text('恢复'), ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Button( - onPressed: () => _restoreBackup(backup), - child: const Text('恢复'), - ), - const SizedBox(width: 8), - IconButton( - icon: const WindowsIcon(WindowsIcons.delete, size: 16), - onPressed: () => _deleteBackup(backup), - ), - ], + const SizedBox(width: 8), + IconButton( + icon: const WindowsIcon( + WindowsIcons.delete, + size: 16, + ), + onPressed: () => _deleteBackup(backup), ), - ), - ); - }, - ), + ], + ), + ), + ); + }, + ), ); } @@ -93,17 +94,21 @@ class _BackupScreenState extends State { await BackupService.createBackup(); await _loadBackups(); if (mounted) { - displayInfoBar(context, builder: (context, close) { - return InfoBar( - title: const Text('备份已创建'), - severity: InfoBarSeverity.success, - onClose: close, - ); - }); + displayInfoBar( + context, + builder: (context, close) { + return InfoBar( + title: const Text('备份已创建'), + severity: InfoBarSeverity.success, + onClose: close, + ); + }, + ); } } Future _restoreBackup(BackupInfo backup) async { + final cardProvider = context.read(); final confirmed = await showConfirmDialog( context: context, title: '恢复备份', @@ -114,18 +119,21 @@ class _BackupScreenState extends State { setState(() => _isLoading = true); await BackupService.restoreBackup(backup.path); - await context.read().loadCards(); + await cardProvider.loadCards(); await _loadBackups(); setState(() => _isLoading = false); if (mounted) { - displayInfoBar(context, builder: (context, close) { - return InfoBar( - title: const Text('备份已恢复'), - severity: InfoBarSeverity.success, - onClose: close, - ); - }); + displayInfoBar( + context, + builder: (context, close) { + return InfoBar( + title: const Text('备份已恢复'), + severity: InfoBarSeverity.success, + onClose: close, + ); + }, + ); } } diff --git a/lib/presentation/screens/study_screen.dart b/lib/presentation/screens/study_screen.dart index 5fd5142..4bd3d54 100644 --- a/lib/presentation/screens/study_screen.dart +++ b/lib/presentation/screens/study_screen.dart @@ -51,12 +51,10 @@ class _StudyScreenState extends State { autofocus: true, child: Column( children: [ - Expanded( - child: _buildBody(provider, padding, isNarrow), - ), + Expanded(child: _buildBody(provider, padding, isNarrow)), StudyStatusBar( - dueCount: provider.dueCount - provider.currentIndex, - totalCount: provider.dueCount, + dueCount: provider.remainingDueCount, + totalCount: provider.totalCount, ), ], ), @@ -69,10 +67,13 @@ class _StudyScreenState extends State { Widget _buildBody(StudyProvider provider, EdgeInsets padding, bool isNarrow) { switch (provider.state) { - case StudyState.empty: + case StudyState.noCards: return EmptyDueWidget( + hasCards: false, onRefresh: () => provider.loadDueCards(), ); + case StudyState.noDueCards: + return EmptyDueWidget(onRefresh: () => provider.loadDueCards()); case StudyState.question: return _buildQuestionView(provider, padding, isNarrow); case StudyState.answer: @@ -80,7 +81,11 @@ class _StudyScreenState extends State { } } - Widget _buildQuestionView(StudyProvider provider, EdgeInsets padding, bool isNarrow) { + Widget _buildQuestionView( + StudyProvider provider, + EdgeInsets padding, + bool isNarrow, + ) { final card = provider.currentCard; if (card == null) return const EmptyDueWidget(); @@ -90,17 +95,16 @@ class _StudyScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CardFaceWidget( - title: '题目', - content: card.q, - ), + CardFaceWidget(title: '题目', content: card.q), SizedBox(height: padding.horizontal > 32 ? 32 : 20), FilledButton( style: ButtonStyle( - padding: WidgetStatePropertyAll(EdgeInsets.symmetric( - horizontal: isNarrow ? 24 : 32, - vertical: isNarrow ? 12 : 16, - )), + padding: WidgetStatePropertyAll( + EdgeInsets.symmetric( + horizontal: isNarrow ? 24 : 32, + vertical: isNarrow ? 12 : 16, + ), + ), ), onPressed: provider.showAnswer, child: const Text('显示答案 (Space)'), @@ -111,7 +115,11 @@ class _StudyScreenState extends State { ); } - Widget _buildAnswerView(StudyProvider provider, EdgeInsets padding, bool isNarrow) { + Widget _buildAnswerView( + StudyProvider provider, + EdgeInsets padding, + bool isNarrow, + ) { final card = provider.currentCard; if (card == null) return const EmptyDueWidget(); @@ -121,16 +129,9 @@ class _StudyScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CardFaceWidget( - title: '题目', - content: card.q, - ), + CardFaceWidget(title: '题目', content: card.q), SizedBox(height: padding.horizontal > 32 ? 24 : 16), - CardFaceWidget( - title: '答案', - content: card.a, - isAnswer: true, - ), + CardFaceWidget(title: '答案', content: card.a, isAnswer: true), SizedBox(height: padding.horizontal > 32 ? 32 : 20), GradeButtons( enabled: provider.canGrade, @@ -144,7 +145,9 @@ class _StudyScreenState extends State { child: Text( '请稍候...', style: FluentTheme.of(context).typography.caption?.copyWith( - color: FluentTheme.of(context).resources.textFillColorSecondary, + color: FluentTheme.of( + context, + ).resources.textFillColorSecondary, ), ), ), diff --git a/lib/presentation/widgets/study/empty_due_widget.dart b/lib/presentation/widgets/study/empty_due_widget.dart index 4818cc2..dc2751f 100644 --- a/lib/presentation/widgets/study/empty_due_widget.dart +++ b/lib/presentation/widgets/study/empty_due_widget.dart @@ -2,8 +2,9 @@ import 'package:fluent_ui/fluent_ui.dart'; class EmptyDueWidget extends StatelessWidget { final VoidCallback? onRefresh; + final bool hasCards; - const EmptyDueWidget({super.key, this.onRefresh}); + const EmptyDueWidget({super.key, this.onRefresh, this.hasCards = true}); @override Widget build(BuildContext context) { @@ -13,28 +14,31 @@ class EmptyDueWidget extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - WindowsIcons.check_mark, + hasCards ? WindowsIcons.check_mark : WindowsIcons.library, size: 64, color: theme.resources.textFillColorSecondary, ), const SizedBox(height: 16), - Text( - '今日任务已完成', - style: theme.typography.title, - ), + if (!hasCards) Text('暂无卡片', style: theme.typography.title), + if (hasCards) Text('今日任务已完成', style: theme.typography.title), const SizedBox(height: 8), - Text( - '系统会定期检查是否有新到期的卡片', - style: theme.typography.caption?.copyWith( - color: theme.resources.textFillColorSecondary, + if (!hasCards) + Text( + '请先添加或导入卡片开始学习', + style: theme.typography.caption?.copyWith( + color: theme.resources.textFillColorSecondary, + ), + ), + if (hasCards) + Text( + '系统会定期检查是否有新到期的卡片', + style: theme.typography.caption?.copyWith( + color: theme.resources.textFillColorSecondary, + ), ), - ), const SizedBox(height: 16), if (onRefresh != null) - Button( - onPressed: onRefresh, - child: const Text('立即检查'), - ), + Button(onPressed: onRefresh, child: const Text('立即检查')), ], ), ); diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f..96d3fee 100644 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" + "info": { + "version": 1, + "author": "xcode" }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d..cd696c6 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eb..a1ff33f 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa..449b62f 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb5722..f6e1870 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318..3a321fa 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e7..001f93e 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632c..94a7bf5 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/pubspec.yaml b/pubspec.yaml index 4ec68c5..7c64432 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: papyrus description: "A new Flutter project." publish_to: 'none' -version: 0.1.0+1 +version: 0.1.1+2 environment: sdk: ^3.11.5 diff --git a/test/unit/card_repository_test.dart b/test/unit/card_repository_test.dart index c10664e..b3819db 100644 --- a/test/unit/card_repository_test.dart +++ b/test/unit/card_repository_test.dart @@ -3,6 +3,7 @@ import 'package:drift/native.dart'; import 'package:papyrus/data/local/app_database.dart'; import 'package:papyrus/data/models/card_model.dart'; import 'package:papyrus/data/repositories/card_repository_impl.dart'; +import 'package:papyrus/mcp/mcp_tool_handler.dart'; void main() { late AppDatabase db; @@ -21,6 +22,7 @@ void main() { test('loadAll returns empty list when no cards', () async { final cards = await repo.loadAll(); expect(cards, isEmpty); + expect(await repo.countCards(), 0); }); test('add and loadAll returns added card', () async { @@ -32,6 +34,7 @@ void main() { expect(cards.first.id, '1'); expect(cards.first.q, 'Q1'); expect(cards.first.a, 'A1'); + expect(await repo.countCards(), 1); }); test('update modifies existing card', () async { @@ -59,8 +62,12 @@ void main() { test('search filters by question and answer', () async { await repo.add(CardModel(id: '1', q: 'Flutter basics', a: 'UI toolkit')); - await repo.add(CardModel(id: '2', q: 'Dart language', a: 'Programming language')); - await repo.add(CardModel(id: '3', q: 'React native', a: 'Mobile framework')); + await repo.add( + CardModel(id: '2', q: 'Dart language', a: 'Programming language'), + ); + await repo.add( + CardModel(id: '3', q: 'React native', a: 'Mobile framework'), + ); final results = await repo.search('flutter'); expect(results.length, 1); @@ -73,9 +80,15 @@ void main() { test('getDueCards returns only due cards sorted by nextReview', () async { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - await repo.add(CardModel(id: '1', q: 'Past', a: 'A', nextReview: now - 100)); - await repo.add(CardModel(id: '2', q: 'Future', a: 'A', nextReview: now + 1000)); - await repo.add(CardModel(id: '3', q: 'Past2', a: 'A', nextReview: now - 50)); + await repo.add( + CardModel(id: '1', q: 'Past', a: 'A', nextReview: now - 100), + ); + await repo.add( + CardModel(id: '2', q: 'Future', a: 'A', nextReview: now + 1000), + ); + await repo.add( + CardModel(id: '3', q: 'Past2', a: 'A', nextReview: now - 50), + ); final due = await repo.getDueCards(); expect(due.length, 2); @@ -85,7 +98,9 @@ void main() { test('getDueCards returns empty when no due cards', () async { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - await repo.add(CardModel(id: '1', q: 'Future', a: 'A', nextReview: now + 1000)); + await repo.add( + CardModel(id: '1', q: 'Future', a: 'A', nextReview: now + 1000), + ); final due = await repo.getDueCards(); expect(due, isEmpty); @@ -116,11 +131,45 @@ void main() { }); test('tags are stored and retrieved correctly', () async { - final card = CardModel(id: '1', q: 'Q', a: 'A', tags: ['dart', 'flutter']); + final card = CardModel( + id: '1', + q: 'Q', + a: 'A', + tags: ['dart', 'flutter'], + ); await repo.add(card); final cards = await repo.loadAll(); expect(cards.first.tags, ['dart', 'flutter']); }); + + test('MCP handler exposes cards, due counts, and reviews by id', () async { + final handler = MCPToolHandler(repo); + final created = await handler.execute('create_card', { + 'question': 'Question', + 'answer': 'Answer', + 'tags': ['test'], + }); + final id = (created['card'] as Map)['id'] as String; + + final stats = await handler.execute('get_card_stats', {}); + expect(stats, {'due_count': 1, 'total_cards': 1}); + + final due = await handler.execute('get_due_cards', {}); + expect(due['due_count'], 1); + expect(due['total_cards'], 1); + + final reviewed = await handler.execute('review_card', { + 'id': id, + 'grade': 3, + }); + final reviewedCard = reviewed['card'] as Map; + expect(reviewedCard['id'], id); + expect(reviewedCard['next_review'], greaterThan(0)); + + final noLongerDue = await handler.execute('get_due_cards', {}); + expect(noLongerDue['due_count'], 0); + expect(noLongerDue['total_cards'], 1); + }); }); } diff --git a/test/unit/mcp_server_test.dart b/test/unit/mcp_server_test.dart new file mode 100644 index 0000000..7d61f95 --- /dev/null +++ b/test/unit/mcp_server_test.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/local/app_database.dart'; +import 'package:papyrus/data/repositories/card_repository_impl.dart'; +import 'package:papyrus/logging/logger.dart'; +import 'package:papyrus/mcp/mcp_server.dart'; + +void main() { + test('MCP server serves health, tools, and structured calls', () async { + final db = AppDatabase(NativeDatabase.memory()); + Logger.instance.initialize(db); + final server = MCPServer(CardRepositoryImpl(db)); + final client = HttpClient(); + + try { + await server.start(port: 0); + final port = server.boundPort; + expect(port, isNotNull); + + final health = await _getJson(client, port!, '/health'); + expect(health['status'], 'ok'); + expect(health['api_version'], '1.1'); + + final tools = await _getJson(client, port, '/tools'); + final names = (tools['tools'] as List) + .cast>() + .map((tool) => tool['name']) + .toSet(); + expect( + names, + containsAll(['list_cards', 'get_card', 'get_due_cards', 'review_card']), + ); + + final create = await _postJson(client, port, '/call', { + 'tool': 'create_card', + 'params': {'question': 'Question', 'answer': 'Answer'}, + }); + final result = create['result'] as Map; + expect((result['card'] as Map)['q'], 'Question'); + } finally { + client.close(force: true); + await server.stop(); + await Future.delayed(const Duration(milliseconds: 50)); + await db.close(); + } + }); +} + +Future> _getJson( + HttpClient client, + int port, + String path, +) async { + final request = await client.getUrl(Uri.parse('http://127.0.0.1:$port$path')); + return _readJson(await request.close()); +} + +Future> _postJson( + HttpClient client, + int port, + String path, + Map body, +) async { + final request = await client.postUrl( + Uri.parse('http://127.0.0.1:$port$path'), + ); + request.headers.contentType = ContentType.json; + request.write(jsonEncode(body)); + return _readJson(await request.close()); +} + +Future> _readJson(HttpClientResponse response) async { + expect(response.statusCode, HttpStatus.ok); + final body = await utf8.decoder.bind(response).join(); + return jsonDecode(body) as Map; +} diff --git a/test/unit/study_provider_test.dart b/test/unit/study_provider_test.dart new file mode 100644 index 0000000..4ba4013 --- /dev/null +++ b/test/unit/study_provider_test.dart @@ -0,0 +1,74 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/local/app_database.dart'; +import 'package:papyrus/data/models/card_model.dart'; +import 'package:papyrus/data/repositories/card_repository_impl.dart'; +import 'package:papyrus/presentation/providers/study_provider.dart'; + +void main() { + late AppDatabase db; + late CardRepositoryImpl repository; + late StudyProvider provider; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + repository = CardRepositoryImpl(db); + provider = StudyProvider(repository); + }); + + tearDown(() async { + provider.dispose(); + await db.close(); + }); + + test('distinguishes an empty card library from an empty due queue', () async { + await provider.loadDueCards(); + + expect(provider.state, StudyState.noCards); + expect(provider.totalCount, 0); + expect(provider.remainingDueCount, 0); + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + await repository.add( + CardModel( + id: 'future', + q: 'Future question', + a: 'Future answer', + nextReview: now + 3600, + ), + ); + await provider.loadDueCards(); + + expect(provider.state, StudyState.noDueCards); + expect(provider.totalCount, 1); + expect(provider.remainingDueCount, 0); + }); + + test('reports total cards independently from the due queue', () async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + await repository.add( + CardModel( + id: 'due', + q: 'Due question', + a: 'Due answer', + nextReview: now - 1, + ), + ); + await repository.add( + CardModel( + id: 'future', + q: 'Future question', + a: 'Future answer', + nextReview: now + 3600, + ), + ); + + await provider.loadDueCards(); + + expect(provider.state, StudyState.question); + expect(provider.totalCount, 2); + expect(provider.dueCount, 1); + expect(provider.remainingDueCount, 1); + expect(provider.currentCard?.id, 'due'); + }); +} diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..449b62f 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index b749bfe..a911e09 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 88cfd48..001f93e 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index eb9b4d7..a911e09 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index d69c566..001f93e 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/web/manifest.json b/web/manifest.json index 723ba1f..1b38c15 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -3,8 +3,8 @@ "short_name": "papyrus", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", + "background_color": "#FFFFFF", + "theme_color": "#FFFFFF", "description": "A new Flutter project.", "orientation": "portrait-primary", "prefer_related_applications": false, diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20c..c4ce8d2 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ